Add nucleus detail share dialog for WhatsApp and copy-link.

Staff can open recipients for gerais, coordinators, and engaged leaderships without granting access, and coordenadores/lideranças can read geral phones for contact.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 17:25:02 -03:00
parent 5bfa226a7e
commit b483e0cb32
9 changed files with 843 additions and 1 deletions

View file

@ -9,6 +9,7 @@ import {
assignNucleusCoordinatorsFormAction,
loadCoordinatorAssignmentOptions,
} from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/coordinatorAssignmentFormActions'
import { loadNucleusShareRecipients } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions'
import { confirmVoteEstimateFormAction } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/voteEstimateFormActions'
import { ArchiveNucleusDialog } from '@/components/campaign/ArchiveNucleusDialog'
import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
@ -16,6 +17,7 @@ import { CoordinatorAssignmentCard } from '@/components/campaign/CoordinatorAssi
import { CoordinatorAssignmentDialog } from '@/components/campaign/CoordinatorAssignmentDialog'
import { NucleusActiveTab, NucleusActiveTabLoading } from '@/components/campaign/NucleusActiveTab'
import { NucleusTabNav } from '@/components/campaign/NucleusTabNav'
import { ShareNucleusDialogShell } from '@/components/campaign/ShareNucleusDialogShell'
import { TseZoneBadge } from '@/components/campaign/TseZoneBadge'
import { VoteEstimateCard } from '@/components/campaign/VoteEstimateCard'
import { VoteEstimateDialogShell } from '@/components/campaign/VoteEstimateDialogShell'
@ -115,6 +117,11 @@ export default async function NucleusDetailPage({ params, searchParams }: Nucleu
</div>
</div>
<div className="flex flex-wrap gap-2">
<ShareNucleusDialogShell
loadRecipients={loadNucleusShareRecipients.bind(null, view.slug)}
nucleusName={view.name}
senderName={user.name}
/>
{canEdit ? (
<Button asChild variant="outline" className="min-h-11">
<Link href={`/campanha/nucleos/${view.slug}/editar`}>

View file

@ -0,0 +1,40 @@
'use server'
import config from '@payload-config'
import { headers } from 'next/headers'
import { getPayload } from 'payload'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getCampaignInviteBaseURL } from '@/utilities/campaignInviteOrigin'
import {
getNucleusShareRecipients,
type NucleusShareRecipients,
} from '@/utilities/nucleusShareRecipients'
export type NucleusShareRecipientsResult = {
recipients: NucleusShareRecipients
nucleusUrl: string
}
export const loadNucleusShareRecipients = async (
slug: string,
): Promise<NucleusShareRecipientsResult> => {
const [payload, user, requestHeaders] = await Promise.all([
getPayload({ config }),
getCampaignUser(),
headers(),
])
if (!user) throw new Error('Faça login para compartilhar este núcleo.')
const recipients = await getNucleusShareRecipients(payload, user, slug)
const baseURL = getCampaignInviteBaseURL({
requestOrigin: requestHeaders.get('origin'),
forwardedHost: requestHeaders.get('x-forwarded-host') ?? requestHeaders.get('host'),
forwardedProto: requestHeaders.get('x-forwarded-proto'),
})
return {
recipients,
nucleusUrl: `${baseURL}/campanha/nucleos/${slug}`,
}
}

View file

@ -0,0 +1,226 @@
'use client'
import { useState } from 'react'
import { CheckIcon, CopyIcon, LinkIcon, MessageCircleIcon } from 'lucide-react'
import type { NucleusShareRecipientsResult } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { Button } from '@/components/ui/button'
import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Spinner } from '@/components/ui/Spinner'
import type { NucleusShareRecipient } from '@/utilities/nucleusShareRecipients'
import { buildWhatsAppUrl } from '@/utilities/phone'
export const buildNucleusShareMessage = ({
recipientName,
senderName,
nucleusName,
nucleusUrl,
}: {
recipientName: string
senderName: string
nucleusName: string
nucleusUrl: string
}): string =>
`Oi ${recipientName}, aqui é ${senderName} da campanha do Solla. Veja o núcleo ${nucleusName}: ${nucleusUrl}`
const getInitials = (name: string): string => {
const words = name.trim().split(/\s+/).filter(Boolean)
return words
.slice(0, 2)
.map((word) => word[0])
.join('')
.toLocaleUpperCase('pt-BR')
}
const RecipientRow = ({
recipient,
roleLabel,
onOpenWhatsApp,
}: {
recipient: NucleusShareRecipient
roleLabel: string
onOpenWhatsApp: (recipient: NucleusShareRecipient) => void
}) => (
<div className="flex items-center gap-3 py-2">
<Avatar size="lg">
<AvatarFallback>{getInitials(recipient.name)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{recipient.name}</p>
<p className="text-sm text-muted-foreground">{roleLabel}</p>
</div>
<Button
type="button"
size="sm"
className="min-h-9 shrink-0 bg-emerald-600 text-white hover:bg-emerald-700"
onClick={() => onOpenWhatsApp(recipient)}
>
<MessageCircleIcon data-icon="inline-start" aria-hidden="true" />
Abrir
</Button>
</div>
)
const RecipientSection = ({
title,
roleLabel,
recipients,
onOpenWhatsApp,
}: {
title: string
roleLabel: string
recipients: NucleusShareRecipient[]
onOpenWhatsApp: (recipient: NucleusShareRecipient) => void
}) => {
if (recipients.length === 0) return null
return (
<section className="flex flex-col gap-1">
<h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
{title}
</h3>
<div className="divide-y divide-border">
{recipients.map((recipient) => (
<RecipientRow
key={recipient.id}
recipient={recipient}
roleLabel={roleLabel}
onOpenWhatsApp={onOpenWhatsApp}
/>
))}
</div>
</section>
)
}
export const ShareNucleusDialog = ({
data,
loadError,
loading,
nucleusName,
onRetry,
senderName,
}: {
data: NucleusShareRecipientsResult | null
loadError: boolean
loading: boolean
nucleusName: string
onRetry: () => void
senderName: string
}) => {
const [copied, setCopied] = useState(false)
const [copyError, setCopyError] = useState<string | null>(null)
const openWhatsApp = (recipient: NucleusShareRecipient) => {
if (!data) return
const message = buildNucleusShareMessage({
recipientName: recipient.name,
senderName,
nucleusName,
nucleusUrl: data.nucleusUrl,
})
window.open(buildWhatsAppUrl(recipient.phone, message), '_blank', 'noopener,noreferrer')
}
const copyLink = async () => {
if (!data) return
setCopyError(null)
try {
await navigator.clipboard.writeText(data.nucleusUrl)
setCopied(true)
} catch {
setCopyError('Não foi possível copiar o link. Selecione e copie manualmente.')
}
}
return (
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>Compartilhar núcleo</DialogTitle>
<DialogDescription>
{nucleusName} · envia apenas o link, não concede acesso
</DialogDescription>
</DialogHeader>
{loading ? (
<div
className="flex min-h-24 items-center justify-center"
aria-label="Carregando destinatários"
>
<Spinner aria-hidden="true" />
</div>
) : null}
{loadError ? (
<div className="flex flex-col gap-3">
<Alert variant="destructive">
<AlertTitle>Não foi possível carregar</AlertTitle>
<AlertDescription>
Não foi possível listar os destinatários. Tente novamente.
</AlertDescription>
</Alert>
<Button type="button" variant="outline" onClick={onRetry}>
Tentar novamente
</Button>
</div>
) : null}
{copyError ? (
<Alert variant="destructive" aria-live="polite">
<AlertTitle>Cópia indisponível</AlertTitle>
<AlertDescription>{copyError}</AlertDescription>
</Alert>
) : null}
{data && !loading ? (
<div className="flex flex-col gap-5">
<RecipientSection
title="Com coordenação geral"
roleLabel="Coord. Geral"
recipients={data.recipients.general}
onOpenWhatsApp={openWhatsApp}
/>
<RecipientSection
title="Com coordenador"
roleLabel="Coord. Núcleo"
recipients={data.recipients.coordinators}
onOpenWhatsApp={openWhatsApp}
/>
<RecipientSection
title="Com liderança"
roleLabel="Liderança · Engajada"
recipients={data.recipients.leaderships}
onOpenWhatsApp={openWhatsApp}
/>
<section className="flex flex-col gap-2">
<h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
Copiar link
</h3>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
<LinkIcon className="size-4 shrink-0" aria-hidden="true" />
<span className="truncate">{data.nucleusUrl}</span>
</div>
<Button type="button" variant="secondary" className="min-h-11 shrink-0" onClick={copyLink}>
{copied ? (
<CheckIcon data-icon="inline-start" aria-hidden="true" />
) : (
<CopyIcon data-icon="inline-start" aria-hidden="true" />
)}
{copied ? 'Copiado' : 'Copiar'}
</Button>
</div>
</section>
</div>
) : null}
</DialogContent>
)
}

View file

@ -0,0 +1,127 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Share2Icon } from 'lucide-react'
import type { NucleusShareRecipientsResult } from '@/app/(campaign)/campanha/(app)/nucleos/[slug]/shareRecipientsActions'
import { LazyDialogModuleFallback } from '@/components/campaign/LazyDialogModuleFallback'
import { useLazyDialogModule } from '@/components/campaign/useLazyDialogModule'
import { Button } from '@/components/ui/button'
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
type ShareNucleusDialogModule = {
default: typeof import('./ShareNucleusDialog').ShareNucleusDialog
}
const loadShareNucleusDialog = (): Promise<ShareNucleusDialogModule> =>
import('./ShareNucleusDialog').then((module) => ({
default: module.ShareNucleusDialog,
}))
export type LoadNucleusShareRecipientsAction = () => Promise<NucleusShareRecipientsResult>
export const ShareNucleusDialogShell = ({
loadDialogModule = loadShareNucleusDialog,
loadRecipients,
nucleusName,
senderName,
}: {
loadDialogModule?: () => Promise<ShareNucleusDialogModule>
loadRecipients: LoadNucleusShareRecipientsAction
nucleusName: string
senderName: string
}) => {
const [activated, setActivated] = useState(false)
const [data, setData] = useState<NucleusShareRecipientsResult | null>(null)
const [loading, setLoading] = useState(false)
const [loadError, setLoadError] = useState(false)
const requestId = useRef(0)
const {
component: DialogComponent,
load,
resetAfterClose,
status,
} = useLazyDialogModule(loadDialogModule)
const fetchRecipients = useCallback(async () => {
const currentRequest = ++requestId.current
setLoading(true)
setLoadError(false)
setData(null)
try {
const result = await loadRecipients()
if (requestId.current !== currentRequest) return
setData(result)
} catch {
if (requestId.current !== currentRequest) return
setLoadError(true)
} finally {
if (requestId.current === currentRequest) setLoading(false)
}
}, [loadRecipients])
const openDialog = () => {
setActivated(true)
load()
}
const closeDialog = () => {
setActivated(false)
setData(null)
setLoading(false)
setLoadError(false)
requestId.current += 1
resetAfterClose()
}
useEffect(() => {
if (!activated) return
void fetchRecipients()
return () => {
requestId.current += 1
}
}, [activated, fetchRecipients])
return (
<Dialog
open={activated}
onOpenChange={(open) => {
if (open) {
openDialog()
return
}
closeDialog()
}}
>
<DialogTrigger asChild>
<Button type="button" variant="outline" className="min-h-11">
<Share2Icon data-icon="inline-start" aria-hidden="true" />
Compartilhar
</Button>
</DialogTrigger>
{activated ? (
DialogComponent ? (
<DialogComponent
data={data}
loadError={loadError}
loading={loading || status === 'loading'}
nucleusName={nucleusName}
onRetry={() => {
load()
void fetchRecipients()
}}
senderName={senderName}
/>
) : (
<LazyDialogModuleFallback
description={`${nucleusName} · envia apenas o link, não concede acesso`}
loadingLabel="Carregando compartilhamento"
onRetry={load}
status={status === 'ready' ? 'loading' : status}
title="Compartilhar núcleo"
/>
)
) : null}
</Dialog>
)
}

View file

@ -377,7 +377,7 @@ export const getAccessibleNucleusIds = async (
return uniqueIDs
}
export const canReadCampaignUserPhone: FieldAccess = async ({ id, req }) => {
export const canReadCampaignUserPhone: FieldAccess = async ({ doc, id, req }) => {
if (isPayloadAdmin(req.user)) return true
const currentUser = await getFreshCampaignUser(req)
@ -393,6 +393,28 @@ export const canReadCampaignUserPhone: FieldAccess = async ({ id, req }) => {
const cacheKey = `${CAMPAIGN_USER_PHONE_ACCESS_CONTEXT_KEY}:${currentUser.id}:${targetUserID}`
if (typeof context[cacheKey] === 'boolean') return context[cacheKey]
let targetRole =
typeof doc === 'object' && doc !== null && 'role' in doc ? doc.role : undefined
if (targetRole === undefined) {
try {
const target = await req.payload.findByID({
collection: 'campaignUser',
id: targetUserID,
depth: 0,
select: { role: true },
overrideAccess: true,
req,
})
targetRole = target.role
} catch {
// Missing target only — fall through to the coordinator-scope check.
}
}
if (targetRole === 'geral') {
context[cacheKey] = true
return true
}
const nucleusIDs = await getAccessibleNucleusIds(req, currentUser)
if (!nucleusIDs?.length) {
context[cacheKey] = false

View file

@ -0,0 +1,123 @@
import type { Payload } from 'payload'
import type { CampaignUser, Contact } from '@/payload-types'
import { isPopulatedRelationship, relationshipId } from '@/utilities/relationship'
export type NucleusShareRecipient = {
id: number
name: string
phone: string
}
export type NucleusShareRecipients = {
general: NucleusShareRecipient[]
coordinators: NucleusShareRecipient[]
leaderships: NucleusShareRecipient[]
}
type CampaignUserShareDoc = {
id: number
name: string
phone?: string | null
}
type LeadershipShareDoc = {
id: number
contact?: number | Contact | null
}
const emptyCampaignUserDocs: CampaignUserShareDoc[] = []
const emptyLeadershipDocs: LeadershipShareDoc[] = []
const toCampaignUserRecipient = (doc: CampaignUserShareDoc): NucleusShareRecipient | null =>
typeof doc.phone === 'string' && doc.phone.length > 0
? { id: doc.id, name: doc.name, phone: doc.phone }
: null
const toLeadershipRecipient = (doc: LeadershipShareDoc): NucleusShareRecipient | null => {
if (!isPopulatedRelationship<Contact>(doc.contact)) return null
if (typeof doc.contact.phone !== 'string' || doc.contact.phone.length === 0) return null
return {
id: doc.id,
name: doc.contact.name,
phone: doc.contact.phone,
}
}
export const getNucleusShareRecipients = async (
payload: Payload,
user: CampaignUser,
slug: string,
): Promise<NucleusShareRecipients> => {
const nucleusResult = await payload.find({
collection: 'electoralNucleus',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
pagination: false,
select: { coordinators: true },
user,
overrideAccess: false,
})
const nucleus = nucleusResult.docs[0]
if (!nucleus) throw new Error('Núcleo não encontrado ou sem acesso.')
const coordinatorIds = (nucleus.coordinators ?? [])
.map((coordinator) => relationshipId(coordinator))
.filter((id): id is number => id !== null)
// Privileged reads after the nucleus gate: avoids N+1 field-access phone checks
// while still only returning the share sections for a nucleus the actor can open.
const [generalResult, coordinatorsResult, leadershipsResult] = await Promise.all([
payload.find({
collection: 'campaignUser',
where: { role: { equals: 'geral' } },
depth: 0,
pagination: false,
sort: 'name',
select: { name: true, phone: true },
overrideAccess: true,
}),
coordinatorIds.length === 0
? Promise.resolve({ docs: emptyCampaignUserDocs })
: payload.find({
collection: 'campaignUser',
where: {
and: [{ id: { in: coordinatorIds } }, { role: { equals: 'coordenador' } }],
},
depth: 0,
pagination: false,
sort: 'name',
select: { name: true, phone: true },
overrideAccess: true,
}),
user.role === 'lideranca'
? Promise.resolve({ docs: emptyLeadershipDocs })
: payload.find({
collection: 'leadership',
where: {
and: [
{ nucleus: { equals: nucleus.id } },
{ supportStatus: { equals: 'engajado' } },
],
},
depth: 1,
pagination: false,
sort: 'id',
select: { contact: true },
overrideAccess: true,
}),
])
return {
general: generalResult.docs
.map(toCampaignUserRecipient)
.filter((recipient): recipient is NucleusShareRecipient => recipient !== null),
coordinators: coordinatorsResult.docs
.map(toCampaignUserRecipient)
.filter((recipient): recipient is NucleusShareRecipient => recipient !== null),
leaderships: leadershipsResult.docs
.map(toLeadershipRecipient)
.filter((recipient): recipient is NucleusShareRecipient => recipient !== null),
}
}

View file

@ -0,0 +1,154 @@
// @vitest-environment node
import { beforeAll, describe, expect, it } from 'vitest'
import { getPayload, type Payload } from 'payload'
import config from '@/payload.config'
import { getNucleusShareRecipients } from '@/utilities/nucleusShareRecipients'
import { withCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
describe('getNucleusShareRecipients', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('returns geral, nucleus coordinators, and engaged leaderships for staff', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const generalPhone = fixtures.phone()
const coordinatorPhone = fixtures.phone()
const engagedPhone = fixtures.phone()
const general = await fixtures.createCampaignUser('geral', { phone: generalPhone })
const coordinator = await fixtures.createCampaignUser('coordenador', {
phone: coordinatorPhone,
})
const geralAsCoordinator = await fixtures.createCampaignUser('geral', {
phone: fixtures.phone(),
})
const nucleus = await fixtures.createNucleus({
coordinators: [coordinator.id, geralAsCoordinator.id],
})
const engagedContact = await fixtures.createContact({
name: fixtures.value('Liderança engajada'),
phone: engagedPhone,
})
const engaged = await fixtures.createLeadership({
contact: engagedContact,
nucleus: nucleus.id,
supportStatus: 'engajado',
createdBy: general,
})
const pendingContact = await fixtures.createContact({
name: fixtures.value('Liderança pendente'),
phone: fixtures.phone(),
})
await fixtures.createLeadership({
contact: pendingContact,
nucleus: nucleus.id,
supportStatus: 'a_abordar',
createdBy: general,
})
const recipients = await getNucleusShareRecipients(payload, general, nucleus.slug)
expect(recipients.general.map((item) => item.id)).toEqual(
expect.arrayContaining([general.id, geralAsCoordinator.id]),
)
expect(recipients.general.every((item) => Boolean(item.phone))).toBe(true)
expect(recipients.coordinators).toEqual([
{ id: coordinator.id, name: coordinator.name, phone: coordinatorPhone },
])
expect(recipients.leaderships).toEqual([
{ id: engaged.id, name: engagedContact.name, phone: engagedPhone },
])
})
})
it('hides leadership recipients for lideranca and still exposes geral phones', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const generalPhone = fixtures.phone()
const coordinatorPhone = fixtures.phone()
const general = await fixtures.createCampaignUser('geral', { phone: generalPhone })
const coordinator = await fixtures.createCampaignUser('coordenador', {
phone: coordinatorPhone,
})
const leader = await fixtures.createCampaignUser('lideranca', {
phone: fixtures.phone(),
})
const nucleus = await fixtures.createNucleus({ coordinators: [coordinator.id] })
const contact = await fixtures.createContact({
name: fixtures.value('Contato líder'),
phone: fixtures.phone(),
})
await fixtures.createLeadership({
contact,
nucleus: nucleus.id,
user: leader,
supportStatus: 'engajado',
createdBy: general,
})
await fixtures.createLeadership({
contact: await fixtures.createContact({
name: fixtures.value('Outra liderança'),
phone: fixtures.phone(),
}),
nucleus: nucleus.id,
supportStatus: 'engajado',
createdBy: general,
})
const recipients = await getNucleusShareRecipients(payload, leader, nucleus.slug)
expect(
recipients.general.some((item) => item.id === general.id && item.phone === generalPhone),
).toBe(true)
expect(recipients.coordinators).toEqual([
{ id: coordinator.id, name: coordinator.name, phone: coordinatorPhone },
])
expect(recipients.leaderships).toEqual([])
})
})
it('omits recipients without a readable phone', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const generalWithPhone = await fixtures.createCampaignUser('geral', {
phone: fixtures.phone(),
})
const generalWithoutPhone = await fixtures.createCampaignUser('geral')
const coordinatorWithoutPhone = await fixtures.createCampaignUser('coordenador')
const nucleus = await fixtures.createNucleus({
coordinators: [coordinatorWithoutPhone.id],
})
const recipients = await getNucleusShareRecipients(
payload,
generalWithPhone,
nucleus.slug,
)
expect(recipients.general.map((item) => item.id)).toContain(generalWithPhone.id)
expect(recipients.general.map((item) => item.id)).not.toContain(generalWithoutPhone.id)
expect(recipients.coordinators).toEqual([])
})
})
it('rejects inaccessible nuclei', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const foreignCoordinator = await fixtures.createCampaignUser('coordenador', {
phone: fixtures.phone(),
})
const outsider = await fixtures.createCampaignUser('coordenador', {
phone: fixtures.phone(),
})
const nucleus = await fixtures.createNucleus({
coordinators: [foreignCoordinator.id],
})
await expect(getNucleusShareRecipients(payload, outsider, nucleus.slug)).rejects.toThrow(
'Núcleo não encontrado ou sem acesso.',
)
})
})
})

View file

@ -175,6 +175,33 @@ describe('campaign user contact phone', () => {
})
})
it('lets coordenador and lideranca read contact phones of geral users', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const generalPhone = fixtures.phone()
const general = await fixtures.createCampaignUser('geral', { phone: generalPhone })
const coordinator = await fixtures.createCampaignUser('coordenador', {
phone: fixtures.phone(),
})
const leader = await fixtures.createCampaignUser('lideranca', {
phone: fixtures.phone(),
})
const nucleus = await fixtures.createNucleus({ coordinators: [coordinator.id] })
await createEngagedPhoneAccessGraph(fixtures, general, leader, nucleus.id)
for (const viewer of [coordinator, leader]) {
const visible = await payload.findByID({
collection: 'campaignUser',
id: general.id,
depth: 0,
select: { name: true, phone: true },
user: viewer,
overrideAccess: false,
})
expect(visible.phone).toBe(generalPhone)
}
})
})
it('allows owner and geral field updates but protects create from ordinary users', async () => {
await withCampaignFixtures(payload, async (fixtures) => {
const owner = await fixtures.createCampaignUser('lideranca', {

View file

@ -0,0 +1,116 @@
import { createElement } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ShareNucleusDialog, buildNucleusShareMessage } from '@/components/campaign/ShareNucleusDialog'
import { ShareNucleusDialogShell } from '@/components/campaign/ShareNucleusDialogShell'
import type { NucleusShareRecipients } from '@/utilities/nucleusShareRecipients'
class TestResizeObserver {
observe = () => undefined
unobserve = () => undefined
disconnect = () => undefined
}
globalThis.ResizeObserver ??= TestResizeObserver
const recipientsFixture = (): NucleusShareRecipients => ({
general: [{ id: 1, name: 'Carlos Mendes', phone: '71999990001' }],
coordinators: [{ id: 2, name: 'João Santos', phone: '71999990002' }],
leaderships: [{ id: 3, name: 'Dona Rosa Ferreira', phone: '71999990003' }],
})
describe('campaign nucleus share interactions', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('builds the fixed WhatsApp message template', () => {
expect(
buildNucleusShareMessage({
recipientName: 'Carlos Mendes',
senderName: 'Ana',
nucleusName: 'Quilombo Rio das Rãs',
nucleusUrl: 'https://example.com/campanha/nucleos/quilombo-rio-das-ras',
}),
).toBe(
'Oi Carlos Mendes, aqui é Ana da campanha do Solla. Veja o núcleo Quilombo Rio das Rãs: https://example.com/campanha/nucleos/quilombo-rio-das-ras',
)
})
it('loads recipients on open, copies the nucleus link, and opens WhatsApp', async () => {
const nucleusUrl = 'https://example.com/campanha/nucleos/quilombo-rio-das-ras'
const loadRecipients = vi.fn(async () => ({
recipients: recipientsFixture(),
nucleusUrl,
}))
const writeText = vi.fn(async () => undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const ShareDialogWithProps = (
props: Parameters<typeof ShareNucleusDialog>[0],
) => createElement(ShareNucleusDialog, props)
render(
createElement(ShareNucleusDialogShell, {
loadDialogModule: async () => ({ default: ShareDialogWithProps }),
loadRecipients,
nucleusName: 'Quilombo Rio das Rãs',
senderName: 'Ana Ribeiro',
}),
)
fireEvent.click(screen.getByRole('button', { name: 'Compartilhar' }))
expect(await screen.findByText('Compartilhar núcleo')).toBeTruthy()
await waitFor(() => expect(loadRecipients).toHaveBeenCalledTimes(1))
expect(await screen.findByText('Carlos Mendes')).toBeTruthy()
expect(screen.getByText('João Santos')).toBeTruthy()
expect(screen.getByText('Dona Rosa Ferreira')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Copiar' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith(nucleusUrl))
expect(await screen.findByRole('button', { name: 'Copiado' })).toBeTruthy()
fireEvent.click(screen.getAllByRole('button', { name: 'Abrir' })[0]!)
expect(open).toHaveBeenCalledWith(
expect.stringContaining('https://wa.me/5571999990001?text='),
'_blank',
'noopener,noreferrer',
)
const openedUrl = new URL(String(open.mock.calls[0]?.[0]))
const message = openedUrl.searchParams.get('text') ?? ''
expect(message).toContain('Oi Carlos Mendes, aqui é Ana Ribeiro')
expect(message).toContain(nucleusUrl)
})
it('hides empty recipient sections', async () => {
const loadRecipients = vi.fn(async () => ({
recipients: {
general: [],
coordinators: [],
leaderships: [],
},
nucleusUrl: 'https://example.com/campanha/nucleos/nucleo-vazio',
}))
render(
createElement(ShareNucleusDialogShell, {
loadDialogModule: async () => ({ default: ShareNucleusDialog }),
loadRecipients,
nucleusName: 'Núcleo Vazio',
senderName: 'Coord',
}),
)
fireEvent.click(screen.getByRole('button', { name: 'Compartilhar' }))
await screen.findByText('Copiar link')
expect(screen.queryByText('Com coordenação geral')).toBeNull()
expect(screen.queryByText('Com coordenador')).toBeNull()
expect(screen.queryByText('Com liderança')).toBeNull()
})
})