diff --git a/src/components/campaign/speech/SpeechExcerpt.tsx b/src/components/campaign/speech/SpeechExcerpt.tsx
new file mode 100644
index 00000000..fb777823
--- /dev/null
+++ b/src/components/campaign/speech/SpeechExcerpt.tsx
@@ -0,0 +1,32 @@
+import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
+import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
+
+/**
+ * C216-FOLLOWUP-DRY — the truncated excerpt shared by the speech cards and the
+ * C192 provenance block: a leading `… ` and a trailing ` …` around the
+ * highlighted parts, nothing when there is no excerpt. `quoted` wraps the
+ * provenance excerpt in typographic quotes; `className` keeps each site's own
+ * line-height/margin (the sites differ on purpose).
+ */
+export const SpeechExcerpt = ({
+ excerpt,
+ className,
+ quoted = false,
+}: {
+ excerpt: SpeechHighlightedExcerpt
+ className: string
+ quoted?: boolean
+}) => {
+ if (excerpt.parts.length === 0) return null
+
+ const prefix = excerpt.truncatedStart ? '… ' : ''
+ const suffix = excerpt.truncatedEnd ? ' …' : ''
+
+ return (
+
+ {quoted ? `“${prefix}` : prefix}
+
+ {quoted ? `${suffix}”` : suffix}
+
+ )
+}
diff --git a/src/components/campaign/speech/SpeechResultCard.tsx b/src/components/campaign/speech/SpeechResultCard.tsx
index 956df7b8..b42f9a5d 100644
--- a/src/components/campaign/speech/SpeechResultCard.tsx
+++ b/src/components/campaign/speech/SpeechResultCard.tsx
@@ -2,10 +2,13 @@ import { ExternalLinkIcon, PlayIcon, SparklesIcon } from 'lucide-react'
import Link from 'next/link'
import type { ReactNode } from 'react'
-import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
+import { SpeechExcerpt } from '@/components/campaign/speech/SpeechExcerpt'
import { SpeechNestedCuts } from '@/components/campaign/speech/SpeechNestedCuts'
+import {
+ SpeechResultChips,
+ type SpeechChipGroup,
+} from '@/components/campaign/speech/SpeechResultChips'
import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
-import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
import type { SpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
@@ -20,18 +23,6 @@ const MAX_KEYWORD_CHIPS = 3
const MetaSeparator = () => ·
-const SpeechExcerpt = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt }) => {
- if (excerpt.parts.length === 0) return null
-
- return (
-
- {excerpt.truncatedStart ? '… ' : null}
-
- {excerpt.truncatedEnd ? ' …' : null}
-
- )
-}
-
/**
* C192 — the theme result provenance: the real excerpt that matched the theme,
* with the expanded term highlighted. It replaces the common excerpt (never
@@ -45,11 +36,11 @@ const SpeechThemeProvenance = ({ excerpt }: { excerpt: SpeechHighlightedExcerpt
Por que apareceu
-
- {`“${excerpt.truncatedStart ? '… ' : ''}`}
-
- {`${excerpt.truncatedEnd ? ' …' : ''}”`}
-
+
)
}
@@ -210,14 +201,17 @@ export const SpeechResultCard = ({
)
}
- const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS)
- const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS)
- const visibleKeywords = speech.keywords.slice(0, MAX_KEYWORD_CHIPS)
- const hiddenChips =
- speech.topics.length -
- visibleTopics.length +
- (speech.scopes.length - visibleScopes.length) +
- (speech.keywords.length - visibleKeywords.length)
+ const chipGroups: SpeechChipGroup[] = [
+ { key: 'topics', items: speech.topics, max: MAX_TOPIC_CHIPS },
+ { key: 'scopes', items: speech.scopes, max: MAX_SCOPE_CHIPS },
+ {
+ key: 'keywords',
+ items: speech.keywords,
+ max: MAX_KEYWORD_CHIPS,
+ variant: 'outline',
+ className: 'font-normal text-muted-foreground',
+ },
+ ]
const isTheme = speech.themeMatchTerm !== null
// C192 — a theme card only offers the trecho seek when the speech really
// contains the literal query (a stopword-only segment hit must not claim it).
@@ -228,32 +222,12 @@ export const SpeechResultCard = ({
const bodyNode = isTheme ? (
) : (
-
- )
- const chips = (
- <>
- {visibleTopics.map((topic) => (
-
- {topic.label}
-
- ))}
- {visibleScopes.map((scope) => (
-
- {scope.label}
-
- ))}
- {visibleKeywords.map((keyword) => (
-
- {keyword}
-
- ))}
- {hiddenChips > 0 ? (
-
- +{hiddenChips}
-
- ) : null}
- >
+
)
+ const chips =
const hasChips = Boolean(speech.topics.length || speech.scopes.length || speech.keywords.length)
return (
diff --git a/src/components/campaign/speech/SpeechResultChips.tsx b/src/components/campaign/speech/SpeechResultChips.tsx
new file mode 100644
index 00000000..b3545e98
--- /dev/null
+++ b/src/components/campaign/speech/SpeechResultChips.tsx
@@ -0,0 +1,50 @@
+import { Badge } from '@/components/ui/Badge'
+
+/**
+ * One chip group: `max` bounds how many of its items the card shows; the hidden
+ * ones are summed with the other groups' into a single `+N`. The muted group is
+ * the keyword vocabulary only the Câmara card has.
+ */
+export type SpeechChipGroup = {
+ key: string
+ items: readonly { value: string; label: string }[] | readonly string[]
+ max: number
+ variant?: 'secondary' | 'outline'
+ className?: string
+}
+
+/**
+ * C216-FOLLOWUP-DRY — the chips line shared by the speech cards (C154/C216):
+ * the leading few per group plus one `+N` badge summing every hidden item. The
+ * container and the empty guard stay with the caller.
+ */
+export const SpeechResultChips = ({ groups }: { groups: readonly SpeechChipGroup[] }) => {
+ const hidden = groups.reduce(
+ (total, group) => total + Math.max(0, group.items.length - group.max),
+ 0,
+ )
+
+ return (
+ <>
+ {groups.flatMap((group) =>
+ group.items.slice(0, group.max).map((item) => {
+ const chip = typeof item === 'string' ? { value: item, label: item } : item
+ return (
+
+ {chip.label}
+
+ )
+ }),
+ )}
+ {hidden > 0 ? (
+
+ +{hidden}
+
+ ) : null}
+ >
+ )
+}
diff --git a/src/components/campaign/speech/WebSpeechResultCard.tsx b/src/components/campaign/speech/WebSpeechResultCard.tsx
index 06505b9e..fa5738f6 100644
--- a/src/components/campaign/speech/WebSpeechResultCard.tsx
+++ b/src/components/campaign/speech/WebSpeechResultCard.tsx
@@ -1,10 +1,13 @@
import { PlayIcon, VideoIcon } from 'lucide-react'
import Link from 'next/link'
-import { SpeechHighlightParts } from '@/components/campaign/speech/SpeechHighlightParts'
+import { SpeechExcerpt } from '@/components/campaign/speech/SpeechExcerpt'
+import {
+ SpeechResultChips,
+ type SpeechChipGroup,
+} from '@/components/campaign/speech/SpeechResultChips'
import { SpeechResultThumbnail } from '@/components/campaign/speech/SpeechResultThumbnail'
import { WebSpeechPlatformPill } from '@/components/campaign/speech/WebSpeechPlatformPill'
-import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import type { WebSpeechListItemViewModel } from '@/utilities/speech/speechViewModels'
@@ -21,10 +24,10 @@ const MAX_SCOPE_CHIPS = 2
export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewModel }) => {
const { excerpt, thumbnailUrl, watchHref } = speech
const metaLine = [speech.dateLabel, speech.durationLabel].filter(Boolean).join(' · ')
- const visibleTopics = speech.topics.slice(0, MAX_TOPIC_CHIPS)
- const visibleScopes = speech.scopes.slice(0, MAX_SCOPE_CHIPS)
- const hiddenChips =
- speech.topics.length - visibleTopics.length + (speech.scopes.length - visibleScopes.length)
+ const chipGroups: SpeechChipGroup[] = [
+ { key: 'topics', items: speech.topics, max: MAX_TOPIC_CHIPS },
+ { key: 'scopes', items: speech.scopes, max: MAX_SCOPE_CHIPS },
+ ]
const hasChips = Boolean(speech.topics.length || speech.scopes.length)
return (
@@ -65,31 +68,11 @@ export const WebSpeechResultCard = ({ speech }: { speech: WebSpeechListItemViewM
{speech.title}
- {excerpt.parts.length > 0 ? (
-
- {excerpt.truncatedStart ? '… ' : null}
-
- {excerpt.truncatedEnd ? ' …' : null}
-
- ) : null}
+
{hasChips ? (
- {visibleTopics.map((topic) => (
-
- {topic.label}
-
- ))}
- {visibleScopes.map((scope) => (
-
- {scope.label}
-
- ))}
- {hiddenChips > 0 ? (
-
- +{hiddenChips}
-
- ) : null}
+
) : null}
diff --git a/tests/unit/speechResultCards.unit.spec.ts b/tests/unit/speechResultCards.unit.spec.ts
new file mode 100644
index 00000000..2f9c6e51
--- /dev/null
+++ b/tests/unit/speechResultCards.unit.spec.ts
@@ -0,0 +1,153 @@
+import { createElement } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it } from 'vitest'
+
+import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'
+
+import { SpeechResultCard } from '@/components/campaign/speech/SpeechResultCard'
+import { WebSpeechResultCard } from '@/components/campaign/speech/WebSpeechResultCard'
+import type { SpeechHighlightedExcerpt } from '@/lib/speechHighlight'
+import type {
+ SpeechListItemViewModel,
+ WebSpeechListItemViewModel,
+} from '@/utilities/speech/speechViewModels'
+
+import { stub } from '../helpers/stub'
+
+const mockAppRouter = stub[0]['value']>({
+ push: () => {},
+ replace: () => {},
+ refresh: () => {},
+ forward: () => {},
+ back: () => {},
+ prefetch: () => Promise.resolve(),
+})
+
+const renderWithAppRouter = (element: Parameters[0]) =>
+ renderToStaticMarkup(createElement(AppRouterContext.Provider, { value: mockAppRouter }, element))
+
+const excerpt = (overrides: Partial = {}): SpeechHighlightedExcerpt => ({
+ parts: [
+ { text: 'um trecho sobre ', highlighted: false },
+ { text: 'saúde', highlighted: true },
+ ],
+ truncatedStart: true,
+ truncatedEnd: true,
+ ...overrides,
+})
+
+const speech = (overrides: Partial = {}): SpeechListItemViewModel => ({
+ id: 1,
+ speechAtLabel: '11/08/2026',
+ type: 'Discurso',
+ durationLabel: '2min',
+ presidingOfficer: null,
+ excerpt: excerpt(),
+ matchKind: 'segment',
+ topics: [
+ { value: 'saude', label: 'Saúde' },
+ { value: 'educacao', label: 'Educação' },
+ { value: 'cultura', label: 'Cultura' },
+ { value: 'esporte', label: 'Esporte' },
+ ],
+ scopes: [
+ { value: 'bahia', label: 'Bahia' },
+ { value: 'brasil', label: 'Brasil' },
+ { value: 'internacional', label: 'Internacional' },
+ ],
+ keywords: ['sus', 'vacina', 'atenção básica', 'orçamento'],
+ municipalities: [],
+ thumbnailUrl: null,
+ watchHref: '/campanha/comunicacao/acervo/1',
+ officialTextUrl: null,
+ cuts: [],
+ matchedTextSearch: true,
+ themeMatchTerm: null,
+ ...overrides,
+})
+
+const webSpeech = (
+ overrides: Partial = {},
+): WebSpeechListItemViewModel => ({
+ id: 9,
+ title: 'Fala na internet',
+ platform: { value: 'youtube', label: 'YouTube' },
+ dateLabel: '12/09/2026',
+ durationLabel: '5min',
+ excerpt: excerpt(),
+ topics: [
+ { value: 'saude', label: 'Saúde' },
+ { value: 'educacao', label: 'Educação' },
+ { value: 'cultura', label: 'Cultura' },
+ { value: 'esporte', label: 'Esporte' },
+ ],
+ scopes: [
+ { value: 'bahia', label: 'Bahia' },
+ { value: 'brasil', label: 'Brasil' },
+ { value: 'internacional', label: 'Internacional' },
+ ],
+ thumbnailUrl: null,
+ watchHref: '/campanha/comunicacao/acervo/internet/9',
+ ...overrides,
+})
+
+describe('speech result cards excerpt', () => {
+ it('renders the truncated excerpt with the highlighted part', () => {
+ const html = renderWithAppRouter(createElement(SpeechResultCard, { speech: speech() }))
+
+ expect(html).toContain('text-sm leading-relaxed text-foreground/90')
+ expect(html).toContain('… ')
+ expect(html).toContain(' …')
+ expect(html).toContain(' {
+ const html = renderWithAppRouter(
+ createElement(SpeechResultCard, {
+ speech: speech({
+ excerpt: { parts: [], truncatedStart: false, truncatedEnd: false },
+ }),
+ }),
+ )
+
+ expect(html).not.toContain('um trecho sobre')
+ })
+
+ it('wraps the theme provenance excerpt in quotes', () => {
+ const html = renderWithAppRouter(
+ createElement(SpeechResultCard, { speech: speech({ themeMatchTerm: 'saúde' }) }),
+ )
+
+ expect(html).toContain('Por que apareceu')
+ expect(html).toContain('“')
+ expect(html).toContain('”')
+ })
+
+ it('keeps the web card own excerpt classes', () => {
+ const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() }))
+
+ expect(html).toContain('mt-2 text-sm leading-6 text-foreground/90')
+ })
+})
+
+describe('speech result cards chips', () => {
+ it('caps each group and sums every hidden chip on the Câmara card', () => {
+ const html = renderWithAppRouter(createElement(SpeechResultCard, { speech: speech() }))
+
+ expect(html).toContain('Saúde')
+ expect(html).toContain('Bahia')
+ expect(html).toContain('sus')
+ expect(html).not.toContain('Esporte')
+ expect(html).not.toContain('Internacional')
+ expect(html).not.toContain('orçamento')
+ expect(html).toContain('+3')
+ })
+
+ it('sums only the groups the web card has', () => {
+ const html = renderWithAppRouter(createElement(WebSpeechResultCard, { speech: webSpeech() }))
+
+ expect(html).toContain('+2')
+ expect(html).not.toContain('sus')
+ })
+})