mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
Merge pull request #64 from fsolla/cursor/b55-busca-global-acoes-linha-b307
B55 — Busca global: ações de linha (WA e compartilhar)
This commit is contained in:
commit
a918eb87dd
16 changed files with 282 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
'src/utilities/homeSearch',
|
||||
'src/utilities/campaignDashboardData',
|
||||
'src/lib/campaignHomeSearchHits',
|
||||
'src/lib/homeSearchShare',
|
||||
],
|
||||
specs: ['campaignHomeActions'],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
66
src/components/campaign/dashboard/HomeSearchShareAction.tsx
Normal file
66
src/components/campaign/dashboard/HomeSearchShareAction.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
30
src/lib/homeSearchShare.ts
Normal file
30
src/lib/homeSearchShare.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
|
|
|
|||
57
tests/unit/homeSearchRowActions.unit.spec.tsx
Normal file
57
tests/unit/homeSearchRowActions.unit.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
35
tests/unit/homeSearchShare.unit.spec.ts
Normal file
35
tests/unit/homeSearchShare.unit.spec.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue