B30: convite WhatsApp na lista de lideranças

Staff gera convite de autopreenchimento na coluna de ações da lista, reutilizando createCampaignInvite e alinhando validação de celular ao link de contato do B28.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 20:47:11 -03:00
parent 013f167d47
commit d6455df5e7
9 changed files with 251 additions and 48 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B30 entregue em código (2026-07-26):** convite de completar cadastro por WhatsApp na lista `/campanha/liderancas` — `LeadershipInviteRowAction` (`UserPlusIcon`) na coluna `actions` existente (B28 ✓), ao lado do `MessageCircleIcon` de contato; Popover two-step (`Gerar convite` → Alert com âncora `wa.me` + copiar link) reusa `createCampaignInvite`/`kind: 'autopreenchimento'`; disable alinhado ao `whatsAppHrefForPhone` da mesma célula (`hasValidPhone`); sem migration/action/collection nova. Plano revisado pós-B28 (encaixe em `actions`, não coluna `inviteAction`). Gate: tsc/lint/format/cycles; 532 unit (+2) / 406 int; build não rodou sem `PAYLOAD_SECRET`; knip P3 pré-existente; Aikido 0 findings. Plano: [`convite-whatsapp-lista-liderancas.md`](../../../docs/plans/convite-whatsapp-lista-liderancas.md).
- **B31 entregue em código (2026-07-26):** coluna "Dobradinhas" em `/campanha/liderancas` — chips clicáveis (`StateDeputyChips`, link para `/campanha/dobradinhas/<slug>`) com edição por Popover+Command (adicionar/remover sem "Salvar"), gravação **por delta** com lock (`leadership-state-deputies:{id}`), nunca replace do array inteiro (que `updateLeadershipInternalRecord` continua fazendo, inalterado, para o form completo da ficha). **Auditoria do plano antes de codar** achou 5 defasagens (ordem de colunas mudou com o B28 ✓; B27 ✓ já tinha entregue a mecânica Popover+Command+chips, então B31 é o 2º call site, não o 1º; o precedente de UI correto é duplo — mecânica de `MunicipalityListAdvisorsControl` (B27) + escrita por server-action-direta-do-cliente de `AdvisorMunicipalityCell`/`setAdvisorMunicipalityMembership` (B19), nenhum dos dois sozinho; `MAX_LEADERSHIP_STATE_DEPUTIES` era privado; `loadLeadershipDetail` fazia uma 2ª chamada redundante a `loadStateDeputySummaries`), nenhuma delas mudou a abordagem central do plano. **Fase 1:** `LeadershipRowViewModel.stateDeputyNames: string[]` → `stateDeputies: StateDeputySummary[]` (via `loadStateDeputySummaries`, já existente); `loadLeadershipDetail` parou de duplicar a chamada — deriva `stateDeputyIDs` de `row.stateDeputies`; `MAX_LEADERSHIP_STATE_DEPUTIES` exportado; novo `leadershipStateDeputyMembershipSchema`; novo helper puro `nextStateDeputyIdsAfterMembership` (`src/lib/leadershipStateDeputyMembership.ts`, espelha `nextAdvisorIdsAfterMembership` linha a linha); `loadStateDeputyOptions` ganhou `plainName`/`party`/`slug` no retorno (`StateDeputyRelationOption`) para a célula montar um chip otimista completo sem 2ª busca. **Fase 2:** `setLeadershipStateDeputyMembershipRecord`/`setLeadershipStateDeputyMembership` em `actions/leadership.ts` (transação + `acquireTextAdvisoryLocks` + `revalidatePath` em `/campanha/liderancas`, `/campanha/liderancas/[id]`, `/campanha/dobradinhas` e `/campanha/dobradinhas/<slug>` do deputado tocado); novo `liderancas/formActions.ts` (a rota da lista não tinha um ainda — só `nova/` e `[id]/` tinham). **Fase 3 (Impeccable craft→critique→polish):** catálogo uma vez por tabela via `leadershipColumns(stateDeputyOptions)` (columns factory) + `LeadershipStateDeputiesCell` (chips removíveis + `Command`/`matchesAtWordStart`, `useTransition` chamando a form action diretamente — não `fetch` —, reversão otimista + `toast.error` em falha, `aria-live` de status); coluna nova entre Organizações e Acesso ao app. Sem "Ver mais…" (teto de schema 20, uso típico 0–3); sem filtro/sort por dobradinha (gatilho no B29, ainda não entregue). **`/simplify` (1ª rodada):** corrigido revert otimista por delta (não pelo snapshot inteiro — um toggle falho não apaga mais um toggle anterior já salvo), removido fallback silencioso na adição de chip, extraído `sameIdSet` compartilhado com `MunicipalityListAdvisorsControl` (B27) para `src/lib/sameIdSet.ts`, e o caminho no-op da action parou de pagar uma leitura + revalidação supérfluas. **`/simplify` (2ª rodada, pós-rebase):** o catálogo tinha nascido como `StateDeputyOptionsProvider` (Context) — trocado pela columns factory acima ao confirmar que `MunicipalityList.tsx`/`municipalityListColumns` já resolve o mesmo problema sem Context (1ª rodada tinha validado o Context por falta dessa evidência); `revalidatePath('/campanha/liderancas/[id]', 'page')` trocado pelo id concreto (o template dinâmico invalidava toda ficha de liderança a cada toggle); non-null assertion em `optionById.get(...)!` movida para dentro do `setCurrent` updater. Adiado (documentado no plano): passar o `slug` do deputado pelo form em vez do `findByID` extra na action — fica para o 3º call site. Gate verde (tsc, lint --max-warnings=0, format:check, knip — P3 pré-existente, unit 530/530, int 406/406, build); Aikido 0 achados. Plano: [`dobradinhas-lista-liderancas.md`](../../../docs/plans/dobradinhas-lista-liderancas.md).
- **B25 entregue em código (2026-07-26):** link do território na lista de `/campanha/municipios` — coluna "Território" e card mobile usam `TerritoryLink` → `/campanha/territorios#ti-<slug>`; helper compartilhado `territoryAnchor.ts`; destino com `id` nas linhas parent de `TerritoryList` via `CampaignTable.rowId`; sem migration/action. Gate: tsc/lint/format/knip/check:cycles; unit+int verdes (1 int flake por timeout em suíte cheia, pré-existente); `pnpm build` não rodou neste worktree (falta `PAYLOAD_SECRET` no ambiente). Plano: [`link-territorio-lista-municipios.md`](../../../docs/plans/link-territorio-lista-municipios.md).
- **Fill-in "Limitar mapa à Bahia" entregue em código (2026-07-26):** `BahiaMap` trava pan com `maxBounds: L.latLngBounds(BAHIA_BOUNDS).pad(0.1)` + `maxBoundsViscosity: 1` e zoom-out com `applyBahiaMinZoom` (`getBoundsZoom` + padding 16px alinhado ao `fitBounds`, guarda `Number.isFinite`, recalcula no `ResizeObserver` existente). Único arquivo; props/`MunicipalityMapPanel` intactos. Impeccable B: critique 0 P0/P1; harden/optimize out. Gate: tsc/lint/format/knip (P3 pré-existente)/cycles/test (2 timeouts flaky re-rodados verdes)/build; Aikido 0 findings. Plano: [`limitar-mapa-a-bahia.md`](../../../docs/plans/limitar-mapa-a-bahia.md).

View file

@ -1,17 +1,17 @@
# Convite por WhatsApp na lista de lideranças
Status: rascunho
Atualizado em: 2026-07-25
Status: entregue
Atualizado em: 2026-07-26
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item **B30**)
Impeccable: B — encaixe de uma ação na tabela existente de `/campanha/liderancas`
Appetite: ~0,25–0,5 dia eng; zero migration/collection/action nova; um componente client + uma coluna
Appetite: ~0,25–0,5 dia eng; zero migration/collection/action nova; um componente client encaixado na coluna `actions` existente (B28 ✓)
Responsável: —
## Design (Impeccable)
Âncoras: `PRODUCT.md` (register Field Desk) · tema `data-theme='campaign'` · princípios `campanha-edit-where-you-see.mdc` e `campanha-action-feedback.mdc`.
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (a UI já existe em dois lugares — `LeadershipInviteButtons.tsx` no detalhe e o ícone de WhatsApp em `AdvisorsTable.tsx` — este item só compõe os dois padrões numa coluna nova).
Na implementação (`implement-roadmap-item`): craft compacto → critique → polish (a UI já existe em dois lugares — `LeadershipInviteButtons.tsx` no detalhe e o ícone de WhatsApp em `AdvisorsTable.tsx` / coluna `actions` do B28 — este item compõe convite + contato na mesma célula).
Brief compacto:
@ -27,7 +27,7 @@ Dados: N/A — item é uma ação de disparo (convite), não introduz métrica,
## Contexto
`/campanha/liderancas` (`src/app/(campaign)/campanha/(app)/liderancas/page.tsx`) já lista lideranças numa `CampaignTable` (Pass 2 W1 — colunas como dado, `leadershipColumns`) com as colunas `name` | `supportStatus` | `municipalities` | `organizations` | `appAccess`. `LeadershipRowViewModel` (`src/utilities/leadershipData.ts`) já expõe `phone: string | null` por linha — não falta nenhum dado no loader.
`/campanha/liderancas` (`src/app/(campaign)/campanha/(app)/liderancas/page.tsx`) já lista lideranças numa `CampaignTable` (Pass 2 W1 — `leadershipColumns(stateDeputyOptions)`). Colunas atuais (pós-B28 ✓ / B31 ✓): `name` | `email` | `phone` | `supportStatus` | `municipalities` | `organizations` | `stateDeputies` | `appAccess` | `actions` (WhatsApp de contato `MessageCircleIcon` + `whatsAppHrefForPhone`). `LeadershipRowViewModel` (`src/utilities/leadershipData.ts`) já expõe `phone: string | null` por linha — não falta nenhum dado no loader.
A funcionalidade "convidar para completar cadastro por WhatsApp" já existe e funciona, mas só no detalhe (`/campanha/liderancas/[id]`), via `LeadershipInviteButtons` (`src/components/campaign/invite/LeadershipInviteButtons.tsx`): chama a server action `createCampaignInvite` (`src/app/(campaign)/campanha/actions/invite.ts` → `createCampaignInviteForActor` em `src/utilities/campaignInviteCreation.ts`), que cria um `campaignInvite` com token único (`generateCampaignInviteToken`, expira em 7 dias) e monta a URL do WhatsApp (`buildCampaignInviteWhatsAppLink` em `src/utilities/campaignInvite.ts` → `buildWhatsAppUrl` em `src/lib/phone.ts`). Se `contact.phone` estiver vazio, a action lança `Error('Cadastre o celular da liderança antes de gerar o convite.')` — hoje **só** validado no servidor; o botão do detalhe nunca desabilita por telefone.
@ -39,7 +39,7 @@ Pedido de produto (2026-07-25): adicionar, ao fim de cada linha de `/campanha/li
## Objetivos
- Nova coluna ao final de `leadershipColumns` com uma ação de "Convidar para completar cadastro (WhatsApp)" por linha.
- Encaixar na coluna `actions` existente (B28 ✓) uma ação de "Convidar para completar cadastro (WhatsApp)" por linha, ao lado do atalho de contato.
- Reusa a mesma server action `createCampaignInvite` e o mesmo `kind: 'autopreenchimento'` já usados no detalhe — nenhuma regra de negócio nova, nenhuma mudança de access (`src/utilities/access/invites.ts` já escopa advisor a lideranças acessíveis).
- Botão desabilitado quando `row.phone` for `null` ou não passar em `normalizeBrazilianPhone` (mesmo critério do `AdvisorsTable`), com `aria-label`/tooltip explicando o motivo.
- Sem migration, sem collection nova, sem endpoint novo.
@ -49,13 +49,15 @@ Pedido de produto (2026-07-25): adicionar, ao fim de cada linha de `/campanha/li
- **Two-step reveal (gerar → depois um `<a>` real de "Enviar pelo WhatsApp") em vez de `window.open` automático pós-`await`.** O convite exige uma chamada de servidor (criação de token) antes de existir a URL do WhatsApp; abrir a aba automaticamente depois de um `await` quebra o vínculo síncrono com o gesto do usuário e é bloqueado por popup blockers em Safari/Firefox com frequência. O próprio detalhe (`LeadershipInviteButtons`) já resolve isso mostrando um `Alert` com um link real depois de gerar — este item replica o mesmo contrato, só compactado num `Popover` disparado pela linha. **Rejeitado:** `window.open(whatsappUrl, '_blank')` direto após o `await` da action — risco de bloqueio silencioso, sem precedente no código; o usuário não teria como saber que nada abriu.
- **Reusar `createCampaignInvite`/`kind: 'autopreenchimento'` sem tocar na action, no schema ou na collection `campaignInvite`.** É exatamente a mesma operação já usada no detalhe (mesmo consentimento fail-closed, mesmo token, mesma expiração) — criar uma segunda entrada de convite seria duplicar sem motivo. **Rejeitado:** action dedicada "quick invite" — não há diferença de contrato que justifique um segundo caminho.
- **Critério de "celular válido" = `normalizeBrazilianPhone(row.phone)` retorna não-nulo**, o mesmo helper já usado em `AdvisorsTable.tsx` e dentro de `buildWhatsAppUrl`. **Rejeitado:** checar só `Boolean(row.phone)` — aceitaria strings malformadas que a action rejeitaria de qualquer forma no servidor, produzindo um botão "ativo" que sempre falha.
- **i18n e naming:** identificadores em inglês — componente `LeadershipInviteRowAction` (`src/components/campaign/invite/LeadershipInviteRowAction.tsx`), coluna `id: 'inviteAction'`; strings visíveis em pt-BR ("Convidar por WhatsApp", "Sem celular cadastrado").
- **Ícone distinto do WhatsApp de contato do B28.** Como as duas ações podem coexistir na mesma linha, o ícone deste item usa um glifo diferente do `MessageCircleIcon` genérico (ex.: `SendIcon`/`UserPlusIcon` — craft define o exato) com `aria-label` explícito ("Convidar {nome} para completar cadastro por WhatsApp"), para não parecer um segundo botão de "falar com a pessoa". **Rejeitado:** mesmo ícone dos dois itens — ambíguo sobre qual ação cada clique dispara.
- **i18n e naming:** identificadores em inglês — componente `LeadershipInviteRowAction` (`src/components/campaign/invite/LeadershipInviteRowAction.tsx`); strings visíveis em pt-BR ("Convidar por WhatsApp", "Sem celular cadastrado").
- **Ícone distinto do WhatsApp de contato do B28.** Como as duas ações coexistem na mesma célula `actions`, o ícone deste item é `UserPlusIcon` (não `MessageCircleIcon`) com `aria-label` explícito ("Convidar {nome} para completar cadastro por WhatsApp"). **Rejeitado:** mesmo ícone dos dois itens — ambíguo sobre qual ação cada clique dispara.
## Questões em aberto
- **A coluna nova aparece só para quem pode convidar, ou para todos os papéis que acessam a lista?** **Opções:** A) renderizar sempre, deixando o access da própria `createCampaignInvite` decidir (advisor sem acesso àquela liderança já recebe erro do servidor); B) esconder a coluna para quem não tem permissão de convite. **Recomendação:** A — a página já é staff-only (`isCampaignStaff`, redirect no topo do `page.tsx`) e todo staff que enxerga a linha já pode convidar aquela liderança (mesma regra do detalhe, onde o botão também não checa nada além de estar logado como staff); esconder a coluna duplicaria uma checagem de access que já existe no lado do servidor.
- **O ícone/label da ação repete "Convidar para completar cadastro" (texto longo do detalhe) ou vira só um ícone compacto?** **Opções:** A) botão texto completo (não cabe numa linha de tabela); B) ícone `MessageCircleIcon`/similar com `aria-label` completo, igual ao ícone de WhatsApp da `AdvisorsTable`. **Recomendação:** B — consistente com o precedente e com a densidade da tabela de lideranças (5 colunas hoje).
_(Fechadas na revisão 2026-07-26 — defaults adotados na implementação.)_
- **Visibilidade da ação:** A — renderizar para todo staff que vê a linha; access na `createCampaignInvite`.
- **Ícone compacto:** B — `UserPlusIcon` + `aria-label` completo, densidade da tabela.
## Abordagem proposta
@ -70,9 +72,11 @@ flowchart LR
Componentes:
- **`LeadershipInviteRowAction`** (novo, `src/components/campaign/invite/LeadershipInviteRowAction.tsx`, client): recebe `leadershipID: number`, `name: string`, `phone: string | null`. Calcula validade com `normalizeBrazilianPhone` (`src/lib/phone.ts`, reusado, sem duplicar `whatsAppHrefForPhone`). Renderiza um `Popover` (`@/components/ui/popover`, já em uso no B9/B24/B26) com trigger `Button variant="ghost" size="icon"` (`MessageCircleIcon`, `disabled` quando telefone inválido, `aria-label` diferenciado). Conteúdo do Popover: replica o fluxo de `LeadershipInviteButtons` só para `kind: 'autopreenchimento'` — `useTransition` + chama `createCampaignInvite({ leadership: leadershipID, kind: 'autopreenchimento' })` (import direto de `@/app/(campaign)/campanha/actions/invite`, sem mudança de assinatura) e, no sucesso, mostra a âncora real `whatsappUrl` (mesmo tratamento de erro de consentimento ausente que `LeadershipInviteButtons` já tem).
- **`leadershipColumns`** (`src/app/(campaign)/campanha/(app)/liderancas/page.tsx`): novo item ao final do array, `id: 'inviteAction'`, `head: <CampaignTableHead align="right"><span className="sr-only">Ações</span></CampaignTableHead>`, `cell: (row) => <LeadershipInviteRowAction leadershipID={row.id} name={row.name} phone={row.phone} />`, `cellClassName: 'text-right'` (mesmo padrão de `AdvisorsTable`).
- **Sem migration, sem collection, sem server action nova, sem endpoint.** `LeadershipRowViewModel.phone` já existe no loader; `createCampaignInvite` e `campaignInvite` ficam intactos.
- **`LeadershipInviteRowAction`** (novo, `src/components/campaign/invite/LeadershipInviteRowAction.tsx`, client): recebe `leadershipID: number`, `name: string`, `phone: string | null`. Calcula validade com `normalizeBrazilianPhone`. Trigger `Button variant="ghost" size="icon"` com `UserPlusIcon`, `disabled` quando telefone inválido. Popover replica o fluxo de `LeadershipInviteButtons` só para `kind: 'autopreenchimento'` — `useTransition` + `createCampaignInvite` + Alert com âncora `whatsappUrl` e "Copiar link".
- **`leadershipColumns` → célula `id: 'actions'`:** `inline-flex justify-end gap-1` com `LeadershipInviteRowAction` + botão WhatsApp de contato do B28 (inalterado em comportamento).
- **Sem migration, sem collection, sem server action nova, sem endpoint.**
**Revisão 2026-07-26:** auditoria pré-implementação achou o plano defasado (coluna `inviteAction` separada e contagem de colunas pré-B28/B31); as-built encaixa na coluna `actions` com `UserPlusIcon`. **Entregue em código (2026-07-26):** `LeadershipInviteRowAction` + testes em `campaignInviteInteractions.unit.spec.ts`; gate tsc/lint/format/cycles/532 unit/406 int; `pnpm build` não rodou neste worktree (falta `PAYLOAD_SECRET`); knip P3 pré-existente. **Pós-`/simplify` (mesmo dia):** disable via `hasValidPhone` (mesmo critério que `whatsAppHrefForPhone` na célula); erros de convite em `src/lib/campaignInviteClient.ts` (`mapCreateCampaignInviteError` + constante compartilhada com `campaignInviteCreation`).
## Dependências
@ -94,6 +98,8 @@ Componentes:
## Adiado com gatilho
- **Convite de acesso ao app (`kind: 'login'`) também na lista.** Revisitar se o coordenador pedir a mesma ação para lideranças `engajado` sem abrir o detalhe — nesse ponto o componente já criado aqui vira a base (um segundo trigger no mesmo Popover).
- **`CampaignInviteDeliveryAlert` compartilhado (Alert + WhatsApp + copiar link).** Só após um 3º call site além de `LeadershipInviteButtons` e `LeadershipInviteRowAction` (regra depth check).
- **Unificar string `MISSING_CONSENT` em `campaignInviteRedemption` / defaults de `campaignConsent`.** No próximo toque nesses módulos; constante canônica já vive em `campaignInviteClient.ts`.
## Referências

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,7 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
import { LeadershipStateDeputiesCell } from '@/components/campaign/leadership/LeadershipStateDeputiesCell'
import { SupportStatusBadge } from '@/components/campaign/leadership/SupportStatusBadge'
import { CampaignCopyableCell } from '@/components/campaign/shared/CampaignCopyableCell'
@ -125,28 +126,37 @@ const leadershipColumns = (
cellClassName: 'text-right',
cell: (row) => {
const whatsAppHref = whatsAppHrefForPhone(row.phone)
return whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
disabled
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</Button>
return (
<div className="inline-flex items-center justify-end gap-1">
<LeadershipInviteRowAction
leadershipID={row.id}
name={row.name}
hasValidPhone={whatsAppHref !== null}
/>
{whatsAppHref ? (
<Button asChild variant="ghost" size="icon" className="size-10">
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Enviar WhatsApp para ${row.name}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
) : (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
disabled
aria-label={`WhatsApp indisponível — ${row.name} sem celular`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</Button>
)}
</div>
)
},
},

View file

@ -6,6 +6,7 @@ import { createCampaignInvite } from '@/app/(campaign)/campanha/actions/invite'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { mapCreateCampaignInviteError } from '@/lib/campaignInviteClient'
type InviteKind = 'autopreenchimento' | 'login'
@ -41,11 +42,7 @@ export const LeadershipInviteButtons = ({
const invite = await createCampaignInvite({ leadership: leadershipID, kind })
setResult({ kind, ...invite })
} catch (cause) {
setError(
cause instanceof Error && cause.message === 'Consentimento ainda não configurado.'
? 'Consentimento ainda não configurado — peça a um admin para criar o texto de consentimento antes de convidar.'
: 'Não foi possível gerar o convite. Verifique seu acesso e tente novamente.',
)
setError(mapCreateCampaignInviteError(cause))
}
})
}

View file

@ -0,0 +1,134 @@
'use client'
import { UserPlusIcon } from 'lucide-react'
import { useState, useTransition } from 'react'
import { createCampaignInvite } from '@/app/(campaign)/campanha/actions/invite'
import { Alert, AlertDescription } from '@/components/ui/Alert'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover'
import { Spinner } from '@/components/ui/Spinner'
import { mapCreateCampaignInviteError } from '@/lib/campaignInviteClient'
type LeadershipInviteRowActionProps = {
leadershipID: number
name: string
hasValidPhone: boolean
}
export const LeadershipInviteRowAction = ({
leadershipID,
name,
hasValidPhone,
}: LeadershipInviteRowActionProps) => {
const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition()
const [result, setResult] = useState<{ whatsappUrl: string; inviteUrl: string } | null>(null)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const inviteAriaLabel = hasValidPhone
? `Convidar ${name} para completar cadastro por WhatsApp`
: `Sem celular cadastrado — ${name}`
const resetPopoverState = () => {
setResult(null)
setError(null)
setCopied(false)
}
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
resetPopoverState()
}
}
const generate = () => {
resetPopoverState()
startTransition(async () => {
try {
const invite = await createCampaignInvite({
leadership: leadershipID,
kind: 'autopreenchimento',
})
setResult(invite)
} catch (cause) {
setError(mapCreateCampaignInviteError(cause))
}
})
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-10"
disabled={!hasValidPhone}
aria-label={inviteAriaLabel}
aria-expanded={open}
aria-haspopup="dialog"
>
<UserPlusIcon className="size-4" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<div className="flex flex-col gap-3">
<p className="text-sm font-medium">Convidar por WhatsApp</p>
<p className="text-sm text-muted-foreground">
Gera um link de uso único (7 dias) para {name} completar o cadastro.
</p>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{result ? (
<Alert>
<AlertDescription className="flex flex-col gap-2">
<span>
Convite para completar cadastro gerado. O link é de uso único e expira em 7 dias.
</span>
<span className="flex flex-wrap gap-2">
<Button asChild className="min-h-11">
<a href={result.whatsappUrl} target="_blank" rel="noopener noreferrer">
Enviar pelo WhatsApp
</a>
</Button>
<Button
type="button"
variant="secondary"
className="min-h-11"
onClick={async () => {
await navigator.clipboard.writeText(result.inviteUrl)
setCopied(true)
}}
>
{copied ? 'Link copiado' : 'Copiar link'}
</Button>
</span>
</AlertDescription>
</Alert>
) : (
<Button
type="button"
variant="outline"
className="min-h-11"
disabled={isPending}
onClick={generate}
>
{isPending ? <Spinner data-icon="inline-start" aria-hidden="true" /> : null}
Gerar convite
</Button>
)}
<p className="sr-only" aria-live="polite">
{isPending ? 'Gerando convite…' : null}
</p>
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,10 @@
/** Server action `createCampaignInvite` throws this when the consent key is missing. */
export const CREATE_CAMPAIGN_INVITE_MISSING_CONSENT_MESSAGE =
'Consentimento ainda não configurado.' as const
export const mapCreateCampaignInviteError = (cause: unknown): string => {
if (cause instanceof Error && cause.message === CREATE_CAMPAIGN_INVITE_MISSING_CONSENT_MESSAGE) {
return 'Consentimento ainda não configurado — peça a um admin para criar o texto de consentimento antes de convidar.'
}
return 'Não foi possível gerar o convite. Verifique seu acesso e tente novamente.'
}

View file

@ -2,6 +2,7 @@ import 'server-only'
import type { Payload } from 'payload'
import { CREATE_CAMPAIGN_INVITE_MISSING_CONSENT_MESSAGE } from '@/lib/campaignInviteClient'
import { campaignInviteCreateSchema, type CampaignInviteCreateInput } from '@/lib/schemas/invite'
import type { CampaignUser } from '@/payload-types'
import { isCampaignStaff } from '@/utilities/campaignAccess'
@ -21,8 +22,6 @@ import type { PayloadTransactionRequest } from '@/utilities/payloadTransaction'
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
import { requireRelationshipId } from '@/utilities/relationship'
const MISSING_CONSENT_MESSAGE = 'Consentimento ainda não configurado.'
const getFreshInviteCreator = async (
payload: Payload,
actor: CampaignUser,
@ -49,7 +48,7 @@ export const createCampaignInviteForActor = async (
async ({ req }) => {
requireCampaignInvitePostgres(payload)
const currentActor = await getFreshInviteCreator(payload, actor, req)
await requireLeadershipConsent(payload, req, MISSING_CONSENT_MESSAGE)
await requireLeadershipConsent(payload, req, CREATE_CAMPAIGN_INVITE_MISSING_CONSENT_MESSAGE)
const leadership = await payload.findByID({
collection: 'leadership',
id: data.leadership,

View file

@ -11,6 +11,7 @@ vi.mock('@/app/(campaign)/campanha/actions/invite', () => ({
}))
import { LeadershipInviteButtons } from '@/components/campaign/invite/LeadershipInviteButtons'
import { LeadershipInviteRowAction } from '@/components/campaign/invite/LeadershipInviteRowAction'
describe('campaign invite interactions', () => {
afterEach(() => {
@ -88,4 +89,49 @@ describe('campaign invite interactions', () => {
kind: 'login',
})
})
it('disables the list-row invite trigger when the phone is missing or invalid', () => {
render(
createElement(LeadershipInviteRowAction, {
leadershipID: 9,
name: 'Ana Silva',
hasValidPhone: false,
}),
)
const trigger = screen.getByRole('button', {
name: 'Sem celular cadastrado — Ana Silva',
})
expect((trigger as HTMLButtonElement).disabled).toBe(true)
})
it('generates an autofill invite from the list-row popover', async () => {
const inviteUrl = 'https://example.com/campanha/convite/list-row'
const whatsappUrl = 'https://wa.me/5571999990000?text=Convite'
inviteActionState.createCampaignInvite.mockResolvedValue({ inviteUrl, whatsappUrl })
render(
createElement(LeadershipInviteRowAction, {
leadershipID: 42,
name: 'João Souza',
hasValidPhone: true,
}),
)
fireEvent.click(
screen.getByRole('button', {
name: 'Convidar João Souza para completar cadastro por WhatsApp',
}),
)
fireEvent.click(screen.getByRole('button', { name: 'Gerar convite' }))
await screen.findByText(/Convite para completar cadastro gerado/)
expect(inviteActionState.createCampaignInvite).toHaveBeenCalledWith({
leadership: 42,
kind: 'autopreenchimento',
})
expect(screen.getByRole('link', { name: 'Enviar pelo WhatsApp' }).getAttribute('href')).toBe(
whatsappUrl,
)
})
})