diff --git a/docs/plans/busca-global-acoes-linha.md b/docs/plans/busca-global-acoes-linha.md
index bd6b55a3..096c4cb9 100644
--- a/docs/plans/busca-global-acoes-linha.md
+++ b/docs/plans/busca-global-acoes-linha.md
@@ -1,7 +1,7 @@
# Busca global — ações de linha (WhatsApp / compartilhar)
-Status: rascunho
-Atualizado em: 2026-07-29
+Status: entregue (2026-07-30)
+Atualizado em: 2026-07-30 — as-built: `HomeSearchWhatsAppAction` + `HomeSearchShareAction`; `trailingAction` em `HomeSearchHitRow`; phone em hits de liderança/assessor; share nativo com fallback `wa.me` em demandas/atividades; dobradinhas sem telefone = sem ícone.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B55 — busca global)
Impeccable: B — trailing actions nas linhas de resultado
Appetite: ~0,5–0,75 dia eng; botões + Web Share / wa.me; sem migration
diff --git a/scripts/lib/e2e-affected-manifest.mjs b/scripts/lib/e2e-affected-manifest.mjs
index 459b3ffe..9b277020 100644
--- a/scripts/lib/e2e-affected-manifest.mjs
+++ b/scripts/lib/e2e-affected-manifest.mjs
@@ -71,6 +71,7 @@ export const E2E_AFFECTED_MANIFEST = [
'src/utilities/homeSearch',
'src/utilities/campaignDashboardData',
'src/lib/campaignHomeSearchHits',
+ 'src/lib/homeSearchShare',
],
specs: ['campaignHomeActions'],
},
diff --git a/src/components/campaign/dashboard/HomeSearchActivityGroup.tsx b/src/components/campaign/dashboard/HomeSearchActivityGroup.tsx
index 4c37c456..b933515a 100644
--- a/src/components/campaign/dashboard/HomeSearchActivityGroup.tsx
+++ b/src/components/campaign/dashboard/HomeSearchActivityGroup.tsx
@@ -3,6 +3,7 @@
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
+import { HomeSearchShareAction } from '@/components/campaign/dashboard/HomeSearchShareAction'
import { homeSearchActivityGroupHasHits } from '@/lib/campaignHomeSearchHits'
import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
@@ -28,6 +29,12 @@ export const HomeSearchActivityGroup = () => {
href={`/campanha/atividades/${hit.slug}`}
primary={hit.title}
secondary={hit.secondary}
+ trailingAction={
+
+ }
/>
))}
diff --git a/src/components/campaign/dashboard/HomeSearchAdvisorGroup.tsx b/src/components/campaign/dashboard/HomeSearchAdvisorGroup.tsx
index 9d07ce04..3fc51859 100644
--- a/src/components/campaign/dashboard/HomeSearchAdvisorGroup.tsx
+++ b/src/components/campaign/dashboard/HomeSearchAdvisorGroup.tsx
@@ -3,6 +3,7 @@
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
+import { HomeSearchWhatsAppAction } from '@/components/campaign/dashboard/HomeSearchWhatsAppAction'
import { formatHomeSearchMunicipalityCount } from '@/lib/campaignHomeSearchHits'
import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
@@ -29,6 +30,7 @@ export const HomeSearchAdvisorGroup = () => {
href={`/campanha/assessores/${hit.id}`}
primary={hit.name}
secondary={formatHomeSearchMunicipalityCount(hit.municipalityCount)}
+ trailingAction={}
/>
))}
diff --git a/src/components/campaign/dashboard/HomeSearchDemandGroup.tsx b/src/components/campaign/dashboard/HomeSearchDemandGroup.tsx
index 757ab0c9..fae78c59 100644
--- a/src/components/campaign/dashboard/HomeSearchDemandGroup.tsx
+++ b/src/components/campaign/dashboard/HomeSearchDemandGroup.tsx
@@ -3,6 +3,7 @@
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
+import { HomeSearchShareAction } from '@/components/campaign/dashboard/HomeSearchShareAction'
import { homeSearchDemandGroupHasHits } from '@/lib/campaignHomeSearchHits'
import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
@@ -28,6 +29,12 @@ export const HomeSearchDemandGroup = () => {
href={`/campanha/demandas/${hit.slug}`}
primary={hit.title}
secondary={hit.secondary}
+ trailingAction={
+
+ }
/>
))}
diff --git a/src/components/campaign/dashboard/HomeSearchHitRow.tsx b/src/components/campaign/dashboard/HomeSearchHitRow.tsx
index f0627858..4e41d7ee 100644
--- a/src/components/campaign/dashboard/HomeSearchHitRow.tsx
+++ b/src/components/campaign/dashboard/HomeSearchHitRow.tsx
@@ -14,15 +14,22 @@ export const HomeSearchHitRow = ({
primary,
secondary,
trailing,
+ trailingAction,
showPriority = false,
}: {
href: string
primary: string
secondary?: string
trailing?: ReactNode
+ trailingAction?: ReactNode
showPriority?: boolean
}) => (
-
+
{
@@ -30,8 +37,9 @@ export const HomeSearchHitRow = ({
event.preventDefault()
}}
className={cn(
- 'flex min-h-11 w-full items-center gap-3 py-2.5 text-left text-foreground',
+ 'flex min-h-11 items-center gap-3 py-2.5 text-left text-foreground',
'outline-none focus-visible:ring-2 focus-visible:ring-ring',
+ trailingAction ? 'min-w-0 flex-1' : 'w-full',
)}
>
@@ -45,5 +53,6 @@ export const HomeSearchHitRow = ({
{trailing ? {trailing} : null}
+ {trailingAction ? {trailingAction} : null}
)
diff --git a/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx b/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx
index d97f82c4..d264a5d4 100644
--- a/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx
+++ b/src/components/campaign/dashboard/HomeSearchLeadershipGroup.tsx
@@ -3,6 +3,7 @@
import { useHomeSearchHitLimit } from '@/components/campaign/dashboard/HomeSearchHitBudgetContext'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
+import { HomeSearchWhatsAppAction } from '@/components/campaign/dashboard/HomeSearchWhatsAppAction'
import { homeSearchLeadershipGroupHasHits } from '@/lib/campaignHomeSearchHits'
import { sliceHomeSearchHits } from '@/lib/homeSearchHitBudget'
import { HOME_SEARCH_GROUP_HEADING_CLASS, HOME_SEARCH_GROUP_LIST_CLASS } from '@/lib/homeSearchUi'
@@ -29,6 +30,7 @@ export const HomeSearchLeadershipGroup = () => {
href={`/campanha/liderancas/${hit.id}`}
primary={hit.name}
secondary={hit.municipalitiesSummary || undefined}
+ trailingAction={
}
/>
))}
diff --git a/src/components/campaign/dashboard/HomeSearchShareAction.tsx b/src/components/campaign/dashboard/HomeSearchShareAction.tsx
new file mode 100644
index 00000000..fdce62ca
--- /dev/null
+++ b/src/components/campaign/dashboard/HomeSearchShareAction.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import { Share2Icon } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import {
+ buildHomeSearchDetailUrl,
+ buildHomeSearchShareText,
+ buildHomeSearchWhatsAppShareHref,
+ resolveHomeSearchShareStrategy,
+} from '@/lib/homeSearchShare'
+
+const stopRowNavigation = (event: { stopPropagation: () => void }) => {
+ event.stopPropagation()
+}
+
+export const HomeSearchShareAction = ({
+ title,
+ detailPath,
+}: {
+ title: string
+ detailPath: string
+}) => {
+ const handleShare = async () => {
+ const absoluteUrl = buildHomeSearchDetailUrl(detailPath)
+ const shareText = buildHomeSearchShareText(title, absoluteUrl)
+ const strategy = resolveHomeSearchShareStrategy()
+
+ if (strategy === 'native') {
+ try {
+ await navigator.share({ title, text: shareText, url: absoluteUrl })
+ } catch (error) {
+ if (error instanceof DOMException && error.name === 'AbortError') return
+ window.open(
+ buildHomeSearchWhatsAppShareHref(title, absoluteUrl),
+ '_blank',
+ 'noopener,noreferrer',
+ )
+ }
+ return
+ }
+
+ window.open(
+ buildHomeSearchWhatsAppShareHref(title, absoluteUrl),
+ '_blank',
+ 'noopener,noreferrer',
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/campaign/dashboard/HomeSearchWhatsAppAction.tsx b/src/components/campaign/dashboard/HomeSearchWhatsAppAction.tsx
new file mode 100644
index 00000000..a8b2a632
--- /dev/null
+++ b/src/components/campaign/dashboard/HomeSearchWhatsAppAction.tsx
@@ -0,0 +1,41 @@
+'use client'
+
+import { MessageCircleIcon } from 'lucide-react'
+
+import { Button } from '@/components/ui/button'
+import { whatsAppHrefForPhone } from '@/lib/phone'
+
+const stopRowNavigation = (event: { stopPropagation: () => void }) => {
+ event.stopPropagation()
+}
+
+export const HomeSearchWhatsAppAction = ({
+ phone,
+ contactName,
+}: {
+ phone: string | null
+ contactName: string
+}) => {
+ const whatsAppHref = whatsAppHrefForPhone(phone)
+ if (!whatsAppHref) return null
+
+ return (
+
+ )
+}
diff --git a/src/lib/campaignHomeSearchHits.ts b/src/lib/campaignHomeSearchHits.ts
index ae784999..b7bc2cff 100644
--- a/src/lib/campaignHomeSearchHits.ts
+++ b/src/lib/campaignHomeSearchHits.ts
@@ -22,6 +22,7 @@ export type HomeSearchTerritoryHit = {
export type HomeSearchAdvisorHit = {
id: number
name: string
+ phone: string | null
municipalityCount: number
}
@@ -29,6 +30,7 @@ export type HomeSearchLeadershipHit = {
kind: 'leadership'
id: number
name: string
+ phone: string | null
municipalitiesSummary: string
}
diff --git a/src/lib/homeSearchShare.ts b/src/lib/homeSearchShare.ts
new file mode 100644
index 00000000..5c895717
--- /dev/null
+++ b/src/lib/homeSearchShare.ts
@@ -0,0 +1,30 @@
+/** 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()
+}
+
+const canUseNativeWebShare = (): boolean =>
+ typeof window !== 'undefined' &&
+ typeof navigator !== 'undefined' &&
+ typeof navigator.share === 'function'
+
+export type HomeSearchShareStrategy = 'native' | 'whatsapp'
+
+/** Native Web Share when available; otherwise WhatsApp text share (desktop fallback). */
+export const resolveHomeSearchShareStrategy = (): HomeSearchShareStrategy =>
+ canUseNativeWebShare() ? 'native' : 'whatsapp'
+
+export const buildHomeSearchWhatsAppShareHref = (title: string, absoluteUrl: string): string =>
+ buildWhatsAppTextShareUrl(buildHomeSearchShareText(title, absoluteUrl))
+
+/** Absolute URL for a campaign detail path — client-only call sites. */
+export const buildHomeSearchDetailUrl = (path: string): string => {
+ if (typeof window === 'undefined') return path
+ return new URL(path, window.location.origin).toString()
+}
diff --git a/src/utilities/homeSearch/searchHomeAdvisors.ts b/src/utilities/homeSearch/searchHomeAdvisors.ts
index 12fb677f..6876bb99 100644
--- a/src/utilities/homeSearch/searchHomeAdvisors.ts
+++ b/src/utilities/homeSearch/searchHomeAdvisors.ts
@@ -37,7 +37,7 @@ export const searchHomeAdvisors = async (
limit: 0,
pagination: false,
sort: 'name',
- select: { name: true },
+ select: { name: true, phone: true },
user,
overrideAccess: false,
})
@@ -48,7 +48,12 @@ export const searchHomeAdvisors = async (
if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) {
return null
}
- return { id: doc.id, name: doc.name, normalizedName }
+ return {
+ id: doc.id,
+ name: doc.name,
+ phone: doc.phone ?? null,
+ normalizedName,
+ }
})
.filter((row): row is NonNullable
=> row !== null)
@@ -65,6 +70,7 @@ export const searchHomeAdvisors = async (
hit: {
id: row.id,
name: row.name,
+ phone: row.phone,
municipalityCount: municipalityIdsByAdvisor.get(row.id)?.length ?? 0,
},
}))
diff --git a/src/utilities/homeSearch/searchHomeLeaderships.ts b/src/utilities/homeSearch/searchHomeLeaderships.ts
index a913445d..4a2a02c2 100644
--- a/src/utilities/homeSearch/searchHomeLeaderships.ts
+++ b/src/utilities/homeSearch/searchHomeLeaderships.ts
@@ -20,6 +20,12 @@ import type { CampaignUser, Leadership } from '@/payload-types'
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
import { buildLeadershipListWhere } from '@/utilities/leadership/leadershipListUrl'
+const populatedContactPhone = (contact: unknown): string | null => {
+ if (typeof contact !== 'object' || contact === null || !('phone' in contact)) return null
+ const { phone } = contact
+ return typeof phone === 'string' && phone.length > 0 ? phone : null
+}
+
const municipalityNamesFromLeadership = (doc: Leadership): string[] => {
const names = new Set()
for (const municipality of doc.municipalities ?? []) {
@@ -69,6 +75,7 @@ export const searchHomeLeaderships = async (
tieBreakDesc: number
id: number
name: string
+ phone: string | null
municipalityNames: string[]
}> = []
@@ -84,6 +91,7 @@ export const searchHomeLeaderships = async (
tieBreakDesc: Number.isFinite(tieBreakDesc) ? tieBreakDesc : 0,
id: doc.id,
name,
+ phone: populatedContactPhone(doc.contact),
municipalityNames: municipalityNamesFromLeadership(doc),
})
}
@@ -96,6 +104,7 @@ export const searchHomeLeaderships = async (
kind: 'leadership',
id: row.id,
name: row.name,
+ phone: row.phone,
municipalitiesSummary: truncatedNamesLabel(row.municipalityNames),
}),
)
diff --git a/tests/unit/homeSearchHitBudget.unit.spec.ts b/tests/unit/homeSearchHitBudget.unit.spec.ts
index bc114cf0..b977c9b2 100644
--- a/tests/unit/homeSearchHitBudget.unit.spec.ts
+++ b/tests/unit/homeSearchHitBudget.unit.spec.ts
@@ -68,12 +68,14 @@ describe('allocateHitBudget', () => {
kind: 'leadership',
id: 1,
name: 'Leader A',
+ phone: null,
municipalitiesSummary: 'Cairu',
},
{
kind: 'leadership',
id: 2,
name: 'Leader B',
+ phone: null,
municipalitiesSummary: 'Salvador',
},
],
diff --git a/tests/unit/homeSearchRowActions.unit.spec.tsx b/tests/unit/homeSearchRowActions.unit.spec.tsx
new file mode 100644
index 00000000..1e2a0050
--- /dev/null
+++ b/tests/unit/homeSearchRowActions.unit.spec.tsx
@@ -0,0 +1,57 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
+import { HomeSearchShareAction } from '@/components/campaign/dashboard/HomeSearchShareAction'
+import { HomeSearchWhatsAppAction } from '@/components/campaign/dashboard/HomeSearchWhatsAppAction'
+
+afterEach(() => {
+ cleanup()
+})
+
+describe('HomeSearchWhatsAppAction', () => {
+ it('renders WhatsApp link when phone normalizes', () => {
+ render()
+
+ const link = screen.getByRole('link', { name: 'Abrir no WhatsApp — Maria Silva' })
+ expect(link.getAttribute('href')).toContain('https://wa.me/')
+ expect(link.getAttribute('target')).toBe('_blank')
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer')
+ })
+
+ it('renders nothing when phone is invalid', () => {
+ const { container } = render()
+ expect(container.firstChild).toBeNull()
+ })
+})
+
+describe('HomeSearchShareAction', () => {
+ it('opens WhatsApp fallback when native share is unavailable', () => {
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
+
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Compartilhar — Atividade X' }))
+
+ expect(openSpy).toHaveBeenCalledOnce()
+ expect(openSpy.mock.calls[0]?.[0]).toContain('https://wa.me/?text=')
+
+ openSpy.mockRestore()
+ })
+})
+
+describe('HomeSearchHitRow trailingAction', () => {
+ it('keeps trailing action outside the row link', () => {
+ const { container } = render(
+ Ação}
+ />,
+ )
+
+ const link = container.querySelector('a[href="/campanha/liderancas/1"]')
+ const action = screen.getByRole('button', { name: 'Ação' })
+ expect(link?.contains(action)).toBe(false)
+ })
+})
diff --git a/tests/unit/homeSearchShare.unit.spec.ts b/tests/unit/homeSearchShare.unit.spec.ts
new file mode 100644
index 00000000..f59b4749
--- /dev/null
+++ b/tests/unit/homeSearchShare.unit.spec.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ buildHomeSearchShareText,
+ buildHomeSearchWhatsAppShareHref,
+ buildWhatsAppTextShareUrl,
+ resolveHomeSearchShareStrategy,
+} from '@/lib/homeSearchShare'
+
+describe('homeSearchShare', () => {
+ it('builds share text from title and absolute URL', () => {
+ expect(
+ buildHomeSearchShareText('Atividade X', 'https://campanha.test/campanha/atividades/x'),
+ ).toBe('Atividade X\nhttps://campanha.test/campanha/atividades/x')
+ })
+
+ it('builds wa.me text-only share URL', () => {
+ const href = buildWhatsAppTextShareUrl('Olá\nhttps://example.com')
+ expect(href).toBe('https://wa.me/?text=Ol%C3%A1%0Ahttps%3A%2F%2Fexample.com')
+ })
+
+ it('falls back to whatsapp strategy when navigator.share is unavailable', () => {
+ expect(resolveHomeSearchShareStrategy()).toBe('whatsapp')
+ })
+
+ it('builds WhatsApp share href for desktop fallback', () => {
+ const href = buildHomeSearchWhatsAppShareHref(
+ 'Demanda Y',
+ 'https://campanha.test/campanha/demandas/y',
+ )
+ expect(href).toContain('https://wa.me/?text=')
+ expect(href).toContain('Demanda+Y')
+ expect(href).toContain(encodeURIComponent('https://campanha.test/campanha/demandas/y'))
+ })
+})