merge stage into docs/work-issue-simplify-rename-chat

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 19:59:55 -03:00
commit 768b11d26f
16 changed files with 282 additions and 6 deletions

View file

@ -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

View file

@ -71,6 +71,7 @@ export const E2E_AFFECTED_MANIFEST = [
'src/utilities/homeSearch',
'src/utilities/campaignDashboardData',
'src/lib/campaignHomeSearchHits',
'src/lib/homeSearchShare',
],
specs: ['campaignHomeActions'],
},

View file

@ -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={
<HomeSearchShareAction
title={hit.title}
detailPath={`/campanha/atividades/${hit.slug}`}
/>
}
/>
</li>
))}

View file

@ -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={<HomeSearchWhatsAppAction phone={hit.phone} contactName={hit.name} />}
/>
</li>
))}

View file

@ -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={
<HomeSearchShareAction
title={hit.title}
detailPath={`/campanha/demandas/${hit.slug}`}
/>
}
/>
</li>
))}

View file

@ -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
}) => (
<div className={HOME_SEARCH_HIT_ROW_WRAPPER_CLASS}>
<div
className={cn(
HOME_SEARCH_HIT_ROW_WRAPPER_CLASS,
trailingAction ? 'flex items-center gap-0' : undefined,
)}
>
<Link
href={href}
onMouseDown={(event) => {
@ -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',
)}
>
<span className={HOME_SEARCH_PRIORITY_ICON_SLOT_CLASS} aria-hidden={!showPriority}>
@ -45,5 +53,6 @@ export const HomeSearchHitRow = ({
</span>
{trailing ? <span className="shrink-0">{trailing}</span> : null}
</Link>
{trailingAction ? <span className="shrink-0">{trailingAction}</span> : null}
</div>
)

View file

@ -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={<HomeSearchWhatsAppAction phone={hit.phone} contactName={hit.name} />}
/>
</li>
))}

View file

@ -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 (
<Button
type="button"
variant="ghost"
size="icon"
className="size-10 shrink-0"
aria-label={`Compartilhar — ${title}`}
onClick={(event) => {
stopRowNavigation(event)
void handleShare()
}}
onMouseDown={stopRowNavigation}
>
<Share2Icon className="size-4" aria-hidden="true" />
</Button>
)
}

View file

@ -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 (
<Button
asChild
variant="ghost"
size="icon"
className="size-10 shrink-0"
onClick={stopRowNavigation}
onMouseDown={stopRowNavigation}
>
<a
href={whatsAppHref}
target="_blank"
rel="noopener noreferrer"
aria-label={`Abrir no WhatsApp — ${contactName}`}
>
<MessageCircleIcon className="size-4" aria-hidden="true" />
</a>
</Button>
)
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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<typeof row> => 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,
},
}))

View file

@ -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<string>()
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),
}),
)

View file

@ -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',
},
],

View file

@ -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(<HomeSearchWhatsAppAction phone="71999998888" contactName="Maria Silva" />)
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(<HomeSearchWhatsAppAction phone="123" contactName="Maria Silva" />)
expect(container.firstChild).toBeNull()
})
})
describe('HomeSearchShareAction', () => {
it('opens WhatsApp fallback when native share is unavailable', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
render(<HomeSearchShareAction title="Atividade X" detailPath="/campanha/atividades/x" />)
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(
<HomeSearchHitRow
href="/campanha/liderancas/1"
primary="Maria Silva"
trailingAction={<button type="button">Ação</button>}
/>,
)
const link = container.querySelector('a[href="/campanha/liderancas/1"]')
const action = screen.getByRole('button', { name: 'Ação' })
expect(link?.contains(action)).toBe(false)
})
})

View file

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