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')) + }) +})