@adatechnology/conversations-ui 0.1.0-rc.20 → 0.1.0-rc.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/{chunk-OIDAIVCH.js → chunk-TV4OQRGH.js} +636 -434
  2. package/dist/index.d.ts +142 -9
  3. package/dist/index.js +374 -66
  4. package/dist/preview/index.d.ts +122 -3
  5. package/dist/preview/index.js +137 -21
  6. package/dist/styles.css +112 -0
  7. package/dist/{types-O7kMP1Yn.d.ts → types-C6A_9edv.d.ts} +42 -2
  8. package/package.json +2 -2
  9. package/src/AudioTranscription.test.tsx +115 -0
  10. package/src/AudioTranscription.tsx +249 -0
  11. package/src/ConversationContextPanel.tsx +205 -52
  12. package/src/ConversationLocalesProvider.tsx +28 -0
  13. package/src/MediaRenderer.tsx +37 -6
  14. package/src/MessageBubble.tsx +26 -3
  15. package/src/MessageComposer.tsx +21 -2
  16. package/src/Wallpaper.tsx +27 -13
  17. package/src/conversationTranscript.test.ts +57 -0
  18. package/src/conversationTranscript.ts +29 -4
  19. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  20. package/src/index.ts +11 -0
  21. package/src/preview/ConversationPreview.tsx +13 -4
  22. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  23. package/src/preview/ConversationSimulatorPanel.tsx +89 -0
  24. package/src/preview/createPreviewBridgeClient.ts +37 -1
  25. package/src/preview/createPreviewMediaUploader.ts +82 -0
  26. package/src/preview/createPreviewWebhookClient.test.ts +89 -0
  27. package/src/preview/createPreviewWebhookClient.ts +91 -0
  28. package/src/preview/index.ts +9 -0
  29. package/src/preview/previewMediaUploader.test.ts +61 -0
  30. package/src/providers/types.ts +12 -1
  31. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  32. package/src/settings/TranscriptionSettingsForm.tsx +189 -0
  33. package/src/styles.css +122 -0
  34. package/src/types.ts +26 -0
@@ -1,21 +1,34 @@
1
1
  /**
2
2
  * "Suas Seleções": o que o bot já coletou na conversa. Para o atendente que assume no meio, é a
3
- * diferença entre ler o transcript inteiro e ver o estado em duas linhas.
3
+ * diferença entre ler o transcript inteiro e ver o estado de relance.
4
4
  *
5
5
  * O pacote não interpreta o contexto — ele é `Record<string, unknown>` e cada produto nomeia as
6
6
  * próprias chaves. O host traduz para `entries`; aqui só se decide como mostrar.
7
+ *
8
+ * Desenho em paridade com financiamento-imobiliario-bot/apps/web/src/components/SelectionsSummary.tsx:
9
+ * card com gradiente, badge de contagem, pills de status no cabeçalho e grid de cards com borda
10
+ * colorida por estado. O que ficou diferente de lá, e por quê, está comentado no ponto.
7
11
  */
8
12
 
9
13
  import { useState } from 'react'
14
+ import { Check, ChevronDown, ChevronUp, Clock, Pencil } from 'lucide-react'
10
15
 
11
16
  import { cn } from './lib/cn'
12
17
  import { useIsNarrow } from './useIsNarrow'
13
18
 
19
+ /**
20
+ * `completed` tem valor, `pending` ainda não foi coletado, `editing` é o cliente refazendo a
21
+ * resposta. O host só precisa mandar `status` para o terceiro caso — os dois primeiros saem do
22
+ * próprio `value`, e exigir o campo quebraria quem já usa o painel.
23
+ */
24
+ export type ConversationContextStatus = 'completed' | 'pending' | 'editing'
25
+
14
26
  export interface ConversationContextEntry {
15
27
  key: string
16
28
  label: string
17
29
  value?: string | undefined
18
30
  icon?: string
31
+ status?: ConversationContextStatus
19
32
  }
20
33
 
21
34
  export interface ConversationContextPanelLabels {
@@ -23,13 +36,16 @@ export interface ConversationContextPanelLabels {
23
36
  empty: string
24
37
  collapse: string
25
38
  expand: string
39
+ /** Placeholder do card ainda não coletado. */
40
+ notCollected: string
26
41
  }
27
42
 
28
43
  export const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels = {
29
- title: '📋 Suas Seleções',
44
+ title: 'Suas Seleções',
30
45
  empty: 'Nada coletado ainda nesta conversa.',
31
46
  collapse: 'fechar',
32
47
  expand: 'abrir',
48
+ notCollected: 'a coletar',
33
49
  }
34
50
 
35
51
  export interface ConversationContextPanelClassNames {
@@ -45,83 +61,220 @@ export interface ConversationContextPanelProps {
45
61
  * Estado inicial. Ausente, abre sozinho no desktop quando há algum dado preenchido.
46
62
  *
47
63
  * Existe porque "abre sozinho" nem sempre é o que o produto quer: com 1 de 6 campos preenchidos o
48
- * painel ocupa altura mostrando quase só travessões, e empurra a conversa — que é o que se veio ver.
64
+ * painel ocupa altura mostrando quase só pendências, e empurra a conversa — que é o que se veio ver.
49
65
  */
50
66
  defaultOpen?: boolean
67
+ /**
68
+ * Rótulo do fluxo em curso, exibido em azul ao lado do título — no financiamento é o produto
69
+ * escolhido ("Consórcio", "MCMV"). Ausente, o cabeçalho fica só com título e contagens.
70
+ */
71
+ flowLabel?: string
51
72
  labels?: Partial<ConversationContextPanelLabels>
52
73
  className?: string
53
74
  classNames?: Partial<ConversationContextPanelClassNames>
54
75
  }
55
76
 
77
+ function statusOf(entry: ConversationContextEntry): ConversationContextStatus {
78
+ if (entry.status) return entry.status
79
+ return entry.value ? 'completed' : 'pending'
80
+ }
81
+
82
+ /** Borda e fundo do card por estado. Pendente é tracejado — ver o comentário no grid. */
83
+ const CARD_STYLE: Record<ConversationContextStatus, string> = {
84
+ completed: 'bg-white dark:bg-gray-800 border-2 border-green-200 dark:border-green-800 shadow-sm',
85
+ editing: 'bg-white dark:bg-gray-800 border-2 border-blue-200 dark:border-blue-800 shadow-sm',
86
+ pending: 'border-2 border-dashed border-gray-200 bg-white/40 dark:border-gray-700 dark:bg-gray-800/30',
87
+ }
88
+
89
+ const GLYPH_STYLE: Record<ConversationContextStatus, string> = {
90
+ completed: 'text-green-600 dark:text-green-400',
91
+ editing: 'text-blue-600 dark:text-blue-400',
92
+ pending: 'text-gray-400 dark:text-gray-500',
93
+ }
94
+
95
+ function StatusGlyph({ status }: { status: ConversationContextStatus }) {
96
+ const size = 11
97
+ if (status === 'completed') return <Check size={size} aria-hidden />
98
+ if (status === 'editing') return <Pencil size={size} aria-hidden />
99
+ return <Clock size={size} aria-hidden />
100
+ }
101
+
102
+ function CountPill({
103
+ status,
104
+ count,
105
+ className,
106
+ }: {
107
+ status: ConversationContextStatus
108
+ count: number
109
+ className: string
110
+ }) {
111
+ if (count === 0) return null
112
+
113
+ return (
114
+ <span className={cn('flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium', className)}>
115
+ <StatusGlyph status={status} />
116
+ {count}
117
+ </span>
118
+ )
119
+ }
120
+
56
121
  export function ConversationContextPanel({
57
122
  entries,
58
123
  defaultOpen,
124
+ flowLabel,
59
125
  labels: labelsOverride,
60
126
  className,
61
127
  classNames,
62
128
  }: ConversationContextPanelProps) {
63
129
  const labels = { ...DEFAULT_CONVERSATION_CONTEXT_LABELS, ...labelsOverride }
64
- const filled = entries.filter((entry) => Boolean(entry.value))
65
130
 
66
- // Abre sozinho só quando há algo coletado: um painel de seis traços ocupa a altura da conversa
67
- // sem informar nada. O contador continua visível fechado, que é o dado útil de relance.
131
+ const counts = { completed: 0, pending: 0, editing: 0 }
132
+ for (const entry of entries) counts[statusOf(entry)] += 1
133
+
134
+ // Abre sozinho só quando há algo coletado: um painel de seis pendências ocupa a altura da conversa
135
+ // sem informar nada. As contagens continuam visíveis fechado, que é o dado útil de relance.
68
136
  //
69
137
  // `undefined` = usuário ainda não mexeu. Guardar só um booleano não serve: o contexto chega
70
138
  // depois da montagem, então o estado inicial seria calculado com a lista vazia e o painel ficaria
71
139
  // fechado mesmo com dados.
72
140
  const isNarrow = useIsNarrow()
73
141
  const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
74
- // No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa. O
75
- // contador no cabeçalho já entrega a informação de relance.
76
- const open = manualOpen ?? defaultOpen ?? (!isNarrow && filled.length > 0)
142
+ // No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa.
143
+ const open = manualOpen ?? defaultOpen ?? (!isNarrow && counts.completed > 0)
77
144
 
78
145
  return (
79
- <section className={cn('border-b', classNames?.root, className)}>
80
- <button
81
- type="button"
82
- onClick={() => setManualOpen(!open)}
83
- aria-expanded={open}
84
- title={open ? labels.collapse : labels.expand}
146
+ // Faixa azul clara com separador, como o wrapper do financiamento: sem ela o card encostava
147
+ // direto no papel de parede da conversa e os dois blocos se misturavam.
148
+ <div className={cn('border-b bg-blue-50/60 px-3 py-3 dark:border-gray-700 dark:bg-blue-950/20', className)}>
149
+ <section
85
150
  className={cn(
86
- 'flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-gray-50 dark:hover:bg-gray-800',
87
- classNames?.toggle,
151
+ 'overflow-hidden rounded-lg border border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50 dark:border-blue-900 dark:from-blue-950/30 dark:to-indigo-950/30',
152
+ classNames?.root,
88
153
  )}
89
154
  >
90
- <span aria-hidden className="text-xs">
91
- {open ? '▾' : '▸'}
92
- </span>
93
- <span>{labels.title}</span>
94
- <span className={cn('rounded-full bg-gray-200 px-2 text-xs dark:bg-gray-700', classNames?.counter)}>
95
- {filled.length}/{entries.length}
96
- </span>
97
- {/* Rótulo escrito na ponta direita: o caret sozinho não dizia que a linha inteira fecha o
98
- painel — a pergunta "cadê o botão de fechar?" veio daí. */}
99
- <span aria-hidden className="ml-auto text-xs text-gray-500">
100
- {open ? labels.collapse : labels.expand}
101
- </span>
102
- </button>
103
-
104
- {open ? (
105
- <div className={cn('px-4 pb-3', classNames?.body)}>
106
- {entries.length === 0 ? (
107
- <p className="text-xs text-gray-500">{labels.empty}</p>
108
- ) : (
109
- <dl className="grid grid-cols-2 gap-2 text-xs">
110
- {entries.map((entry) => (
111
- <div key={entry.key} className="min-w-0">
112
- <dt className="truncate text-gray-500">
113
- {entry.icon ? `${entry.icon} ` : ''}
114
- {entry.label}
115
- </dt>
116
- <dd className="truncate font-medium">
117
- {entry.value ? <span className="text-green-700 dark:text-green-400">✓ {entry.value}</span> : '—'}
118
- </dd>
119
- </div>
120
- ))}
121
- </dl>
155
+ <button
156
+ type="button"
157
+ onClick={() => setManualOpen(!open)}
158
+ aria-expanded={open}
159
+ title={open ? labels.collapse : labels.expand}
160
+ className={cn(
161
+ 'flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-2.5 text-left transition-colors hover:bg-blue-100/40 dark:hover:bg-blue-900/20',
162
+ classNames?.toggle,
122
163
  )}
123
- </div>
124
- ) : null}
125
- </section>
164
+ >
165
+ <span className="flex min-w-0 items-center gap-2">
166
+ <span aria-hidden className="text-base leading-none">
167
+ 📋
168
+ </span>
169
+ <span className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{labels.title}</span>
170
+ {flowLabel ? (
171
+ <span className="truncate text-xs font-medium text-blue-600 dark:text-blue-400">{flowLabel}</span>
172
+ ) : null}
173
+ <span
174
+ className={cn(
175
+ 'inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-medium text-white',
176
+ classNames?.counter,
177
+ )}
178
+ >
179
+ {entries.length}
180
+ </span>
181
+ </span>
182
+
183
+ <span className="flex flex-shrink-0 items-center gap-2">
184
+ <CountPill
185
+ status="completed"
186
+ count={counts.completed}
187
+ className="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
188
+ />
189
+ <CountPill
190
+ status="editing"
191
+ count={counts.editing}
192
+ className="bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
193
+ />
194
+ <CountPill
195
+ status="pending"
196
+ count={counts.pending}
197
+ className="bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
198
+ />
199
+ {open ? (
200
+ <ChevronUp size={15} className="flex-shrink-0 text-blue-500" aria-hidden />
201
+ ) : (
202
+ <ChevronDown size={15} className="flex-shrink-0 text-blue-500" aria-hidden />
203
+ )}
204
+ </span>
205
+ </button>
206
+
207
+ {open ? (
208
+ /**
209
+ * Teto de altura com rolagem interna, como no financiamento (`max-h-64 overflow-y-auto`).
210
+ * Sem isso o painel cresce sem limite — e cresce de verdade: o host anexa as chaves de
211
+ * contexto que ele não conhece ao fim da lista, então uma conversa com bastante estado
212
+ * empurrava a conversa inteira para fora da tela.
213
+ *
214
+ * A diferença: lá o teto envolve o cabeçalho também, então ele rola junto e sai de vista.
215
+ * Aqui só o corpo rola — o cabeçalho é o que fecha o painel, e perder o clique de fechar
216
+ * no meio da rolagem é pior do que a economia de uma linha de markup.
217
+ */
218
+ <div
219
+ className={cn(
220
+ 'cv-scrollbar-thin max-h-64 overflow-y-auto border-t border-blue-200 px-4 pb-4 pt-1 dark:border-blue-900',
221
+ classNames?.body,
222
+ )}
223
+ >
224
+ {entries.length === 0 ? (
225
+ <p className="pt-2 text-xs text-gray-500 dark:text-gray-400">{labels.empty}</p>
226
+ ) : (
227
+ /**
228
+ * Três colunas no desktop, como no financiamento — com duas, seis campos viravam uma
229
+ * coluna alta que empurrava a conversa.
230
+ *
231
+ * O que não fizemos igual: lá o painel FILTRA o que não foi coletado, porque a fonte
232
+ * só guarda o que o cliente respondeu. Aqui a lista de campos é conhecida de antemão,
233
+ * e "falta endereço" é justamente o que o atendente precisa ver ao assumir. Então o
234
+ * pendente aparece — tracejado e apagado, para ler como lacuna e não como dado.
235
+ */
236
+ <dl className="mt-2 grid grid-cols-2 gap-2 lg:grid-cols-3">
237
+ {entries.map((entry) => {
238
+ const status = statusOf(entry)
239
+
240
+ return (
241
+ <div
242
+ key={entry.key}
243
+ className={cn('min-w-0 rounded-lg p-2.5 transition-all', CARD_STYLE[status])}
244
+ >
245
+ <dt className="mb-1 flex items-center justify-center gap-1">
246
+ {entry.icon ? (
247
+ <span aria-hidden className="text-base leading-none">
248
+ {entry.icon}
249
+ </span>
250
+ ) : null}
251
+ <span className="truncate text-[10px] font-medium uppercase leading-none tracking-wide text-gray-500 dark:text-gray-400">
252
+ {entry.label}
253
+ </span>
254
+ <span className={cn('flex-shrink-0 leading-none', GLYPH_STYLE[status])}>
255
+ <StatusGlyph status={status} />
256
+ </span>
257
+ </dt>
258
+ {/* `title` no valor: truncado no card, o texto completo só existe no tooltip. */}
259
+ <dd
260
+ className={cn(
261
+ 'truncate text-center text-xs font-semibold',
262
+ entry.value
263
+ ? 'text-gray-900 dark:text-gray-100'
264
+ : 'italic font-normal text-gray-400 dark:text-gray-500',
265
+ )}
266
+ title={entry.value ?? labels.notCollected}
267
+ >
268
+ {entry.value ?? labels.notCollected}
269
+ </dd>
270
+ </div>
271
+ )
272
+ })}
273
+ </dl>
274
+ )}
275
+ </div>
276
+ ) : null}
277
+ </section>
278
+ </div>
126
279
  )
127
280
  }
@@ -22,6 +22,20 @@ export interface ConversationLocales {
22
22
  untitledDocument: string
23
23
  downloadFile: string
24
24
  }
25
+ transcription: {
26
+ label: string
27
+ copy: string
28
+ copied: string
29
+ transcribe: string
30
+ transcribing: string
31
+ retry: string
32
+ failed: string
33
+ /** Áudio processado sem fala detectada — distinto de "não transcrito". */
34
+ empty: string
35
+ unsupported: string
36
+ showMore: string
37
+ showLess: string
38
+ }
25
39
  selection: {
26
40
  select: string
27
41
  }
@@ -53,6 +67,19 @@ const DEFAULT_LOCALES: ConversationLocales = {
53
67
  untitledDocument: 'Documento',
54
68
  downloadFile: 'Baixar',
55
69
  },
70
+ transcription: {
71
+ label: 'Transcrição',
72
+ copy: 'Copiar',
73
+ copied: 'Copiado!',
74
+ transcribe: 'Transcrever áudio',
75
+ transcribing: 'Transcrevendo...',
76
+ retry: 'Transcrever novamente',
77
+ failed: 'Falha ao transcrever — tentar novamente',
78
+ empty: 'Sem fala detectada',
79
+ unsupported: 'Formato de áudio não suportado para transcrição',
80
+ showMore: 'ver transcrição completa',
81
+ showLess: 'ver menos',
82
+ },
56
83
  selection: {
57
84
  select: 'Selecionar',
58
85
  },
@@ -76,6 +103,7 @@ export interface ConversationLocalesProviderProps {
76
103
  export function ConversationLocalesProvider({ children, locales }: ConversationLocalesProviderProps) {
77
104
  const merged: ConversationLocales = {
78
105
  bubble: { ...DEFAULT_LOCALES.bubble, ...locales?.bubble },
106
+ transcription: { ...DEFAULT_LOCALES.transcription, ...locales?.transcription },
79
107
  selection: { ...DEFAULT_LOCALES.selection, ...locales?.selection },
80
108
  dateDivider: { ...DEFAULT_LOCALES.dateDivider, ...locales?.dateDivider },
81
109
  }
@@ -1,10 +1,11 @@
1
1
  import { useState, type ReactNode } from 'react'
2
2
  import { AudioPlayer } from './AudioPlayer'
3
+ import { AudioTranscription } from './AudioTranscription'
3
4
  import { FileIcon } from './FileIcon'
4
5
  import { useConversationLocales } from './ConversationLocalesProvider'
5
6
  import { formatFileSize } from './lib/format'
6
7
  import { cn } from './lib/cn'
7
- import type { MessagePayload } from './types'
8
+ import type { MessagePayload, MessageTranscription } from './types'
8
9
 
9
10
  /**
10
11
  * Rótulo curto do tipo, para a linha de baixo da bolha de documento.
@@ -50,6 +51,13 @@ export interface MediaRendererProps {
50
51
  // loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
51
52
  // porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
52
53
  onResolveUrl?: ResolveMediaUrl
54
+ /**
55
+ * Pede ao backend a transcrição do áudio desta mensagem. Ausente, o bloco de transcrição só exibe
56
+ * o que já veio pronto — sem oferecer um botão que o host não sabe atender.
57
+ *
58
+ * O que devolver é exibido na hora, sem esperar refetch da lista.
59
+ */
60
+ onTranscribeAudio?: () => Promise<MessageTranscription | void>
53
61
  /** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
54
62
  className?: string
55
63
  }
@@ -77,7 +85,13 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
77
85
  return { url, loading, error, load }
78
86
  }
79
87
 
80
- export function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps) {
88
+ export function MediaRenderer({
89
+ message,
90
+ onLightbox,
91
+ onResolveUrl,
92
+ onTranscribeAudio,
93
+ className,
94
+ }: MediaRendererProps) {
81
95
  const { bubble } = useConversationLocales()
82
96
  const eagerSrc = resolveMediaSource(message)
83
97
  const lazy = useLazyMediaUrl(message, onResolveUrl)
@@ -149,12 +163,28 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
149
163
  )
150
164
  }
151
165
  case 'audio': {
166
+ /**
167
+ * Fica fora do ramo de carregamento de propósito: a transcrição não depende dos bytes do
168
+ * áudio. Ler o que o cliente disse sem baixar e tocar a nota de voz é o caminho rápido do
169
+ * atendimento — e é justamente o que se perderia se o bloco só aparecesse depois do play.
170
+ */
171
+ const transcriptionBlock = (
172
+ <AudioTranscription
173
+ transcription={message.transcription}
174
+ isMine={message.direction === 'outbound'}
175
+ {...(onTranscribeAudio ? { onTranscribe: onTranscribeAudio } : {})}
176
+ />
177
+ )
178
+
152
179
  if (!src && canLazyLoad) {
153
180
  return (
154
- <LazyMediaButton
155
- icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="translate-x-0.5"><polygon points="5 3 19 12 5 21 5 3" /></svg>}
156
- label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
157
- />
181
+ <div className="min-w-[200px]">
182
+ <LazyMediaButton
183
+ icon={<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="translate-x-0.5"><polygon points="5 3 19 12 5 21 5 3" /></svg>}
184
+ label={lazy.loading ? bubble.mediaLoading : lazy.error ? bubble.mediaRetry : bubble.listenAudio}
185
+ />
186
+ {transcriptionBlock}
187
+ </div>
158
188
  )
159
189
  }
160
190
  return (
@@ -162,6 +192,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }:
162
192
  {src ? <AudioPlayer src={src} isMine={message.direction === 'outbound'} /> : (
163
193
  <div className="h-12 bg-gray-200 rounded-lg flex items-center justify-center text-gray-400 text-xs">{bubble.mediaUnavailable}</div>
164
194
  )}
195
+ {transcriptionBlock}
165
196
  </div>
166
197
  )
167
198
  }
@@ -1,6 +1,6 @@
1
1
  import { useMemo, useState } from 'react'
2
2
  import { Check } from 'lucide-react'
3
- import type { InteractiveSelection, MessagePayload } from './types'
3
+ import type { InteractiveSelection, MessagePayload, MessageTranscription } from './types'
4
4
  import { useConversationLocales } from './ConversationLocalesProvider'
5
5
  import { StatusTicks } from './StatusTicks'
6
6
  import { MediaRenderer, type ResolveMediaUrl } from './MediaRenderer'
@@ -30,6 +30,12 @@ export interface MessageBubbleProps {
30
30
  * no histórico da inbox: o operador vê o que foi oferecido, sem responder no lugar do cliente.
31
31
  */
32
32
  onInteractiveSelect?: (selection: InteractiveSelection) => void
33
+ /**
34
+ * Pede a transcrição do áudio. Ausente, o balão usa `transcribeAudio` do `ConversationsApi` do
35
+ * contexto — passe apenas para sobrescrever. Sem nenhum dos dois, o bloco de transcrição só exibe
36
+ * o que já veio pronto do backend.
37
+ */
38
+ onTranscribeAudio?: (messageId: string) => Promise<MessageTranscription | void>
33
39
  className?: string
34
40
  }
35
41
 
@@ -51,7 +57,7 @@ const MEDIA_TYPES = new Set(['image', 'audio', 'video', 'document', 'sticker'])
51
57
  // tailwind.config do host expondo as cores `whatsapp.*` — ver Wallpaper.tsx e T6.2.
52
58
  export function MessageBubble({
53
59
  message, isMine, senderName, isFirstInGroup = true, isSelecting = false, isSelected = false, onToggleSelect,
54
- onResolveMediaUrl, onInteractiveSelect, className,
60
+ onResolveMediaUrl, onInteractiveSelect, onTranscribeAudio, className,
55
61
  }: MessageBubbleProps) {
56
62
  const { bubble, selection } = useConversationLocales()
57
63
  const [lightboxSrc, setLightboxSrc] = useState<string | null>(null)
@@ -66,6 +72,18 @@ export function MessageBubble({
66
72
  [onResolveMediaUrl, context?.api],
67
73
  )
68
74
 
75
+ /**
76
+ * `undefined` quando nem o host nem o `ConversationsApi` sabem transcrever — é essa ausência que o
77
+ * bloco de transcrição consulta para decidir se desenha a afordância, mesmo padrão de
78
+ * `takeover`/`release`.
79
+ */
80
+ const requestTranscription = useMemo(() => {
81
+ const transcribe = onTranscribeAudio ?? context?.api?.transcribeAudio?.bind(context.api)
82
+ if (!transcribe) return undefined
83
+ // Devolve o resultado em vez de descartar: é o que o bloco exibe na hora, sem esperar refetch.
84
+ return () => transcribe(message.id)
85
+ }, [onTranscribeAudio, context?.api, message.id])
86
+
69
87
  const bubbleColor = BUBBLE_COLOR[message.sender] ?? BUBBLE_COLOR.customer
70
88
  const hasError = message.status === 'failed'
71
89
  const isMedia = MEDIA_TYPES.has(message.type)
@@ -138,7 +156,12 @@ export function MessageBubble({
138
156
  )}
139
157
 
140
158
  {isMedia ? (
141
- <MediaRenderer message={message} onLightbox={setLightboxSrc} onResolveUrl={resolveMediaUrl} />
159
+ <MediaRenderer
160
+ message={message}
161
+ onLightbox={setLightboxSrc}
162
+ onResolveUrl={resolveMediaUrl}
163
+ {...(requestTranscription ? { onTranscribeAudio: requestTranscription } : {})}
164
+ />
142
165
  ) : isInteractive && message.payload ? (
143
166
  // O texto da mensagem interativa mora dentro do payload (`body.text`), e `content` guarda
144
167
  // só uma cópia achatada para busca — renderizar `content` aqui duplicaria o corpo.
@@ -1,4 +1,5 @@
1
1
  import { useState, useRef, useCallback, type KeyboardEvent, type ChangeEvent, type ReactNode } from 'react'
2
+ import { AudioRecorderButton } from './AudioRecorderButton'
2
3
  import type { ConversationsFeatures } from './types'
3
4
  import { cn } from './lib/cn'
4
5
  import { EmojiPicker } from './EmojiPicker'
@@ -219,6 +220,24 @@ export const MessageComposer = ({
219
220
  }
220
221
  }, [text, setText])
221
222
 
223
+ /**
224
+ * Microfone por padrão, sem o host precisar compor nada.
225
+ *
226
+ * Antes o gravador era só um componente exportado e um slot vazio: cada inbox tinha que lembrar de
227
+ * juntar os dois. Dois produtos, dois resultados — um ligou, o outro não, e a ausência não dava
228
+ * erro nenhum. Um composer de WhatsApp sem microfone está incompleto, então o default certo é ter.
229
+ *
230
+ * Depende de `onAttach` porque áudio gravado é um arquivo para entregar, e microfone que grava sem
231
+ * ter para onde mandar é pior que microfone nenhum — o operador fala e o áudio evapora. É a mesma
232
+ * regra de capacidade usada no resto do pacote: sem a porta, a afordância não aparece.
233
+ *
234
+ * `idleAction` continua vencendo: quem já compunha o próprio gravador (com rótulos, limite de
235
+ * duração ou revisão diferentes) não muda de comportamento ao atualizar.
236
+ */
237
+ const effectiveIdleAction =
238
+ idleAction ??
239
+ (onAttach ? <AudioRecorderButton onRecorded={(file) => onAttach(file)} /> : undefined)
240
+
222
241
  const canSend = text.trim().length > 0 || attachments.length > 0
223
242
  const remaining = maxLength ? maxLength - text.length : null
224
243
 
@@ -310,8 +329,8 @@ export const MessageComposer = ({
310
329
  </>
311
330
  )}
312
331
 
313
- {!canSend && idleAction ? (
314
- <div className="flex-shrink-0">{idleAction}</div>
332
+ {!canSend && effectiveIdleAction ? (
333
+ <div className="flex-shrink-0">{effectiveIdleAction}</div>
315
334
  ) : (
316
335
  <button
317
336
  onClick={sendMessage}
package/src/Wallpaper.tsx CHANGED
@@ -8,7 +8,7 @@
8
8
  * A classe `cv-wallpaper` continua no elemento para quem já sobrescreve por CSS.
9
9
  */
10
10
 
11
- import type { CSSProperties, ReactNode } from 'react'
11
+ import { forwardRef, type CSSProperties, type ReactNode, type UIEvent } from 'react'
12
12
 
13
13
  import { cn } from './lib/cn'
14
14
  import { useIsDarkTheme } from './useDarkMode'
@@ -45,17 +45,31 @@ export interface ConversationWallpaperProps {
45
45
  className?: string
46
46
  /** Ajusta ou substitui o fundo padrão — para produto com identidade visual própria. */
47
47
  style?: CSSProperties
48
+ /**
49
+ * Rolagem da área de mensagens. Par do `ref`: é este elemento que rola, então é aqui que
50
+ * `useScrollToLatestMessage` observa a posição para saber se o operador está acompanhando o fim.
51
+ */
52
+ onScroll?: (event: UIEvent<HTMLDivElement>) => void
48
53
  }
49
54
 
50
- export function ConversationWallpaper({ children, className, style }: ConversationWallpaperProps) {
51
- const isDark = useIsDarkTheme()
52
-
53
- return (
54
- <div
55
- className={cn('cv-wallpaper', className)}
56
- style={{ ...(isDark ? DARK_WALLPAPER : LIGHT_WALLPAPER), ...style }}
57
- >
58
- {children}
59
- </div>
60
- )
61
- }
55
+ /**
56
+ * `forwardRef` porque quem controla a rolagem é de fora — o hook precisa do elemento para saltar
57
+ * até a última mensagem. `forwardRef` e não `ref` como prop: o pacote suporta React 18, onde
58
+ * ref-como-prop ainda não existe.
59
+ */
60
+ export const ConversationWallpaper = forwardRef<HTMLDivElement, ConversationWallpaperProps>(
61
+ function ConversationWallpaper({ children, className, style, onScroll }, ref) {
62
+ const isDark = useIsDarkTheme()
63
+
64
+ return (
65
+ <div
66
+ ref={ref}
67
+ onScroll={onScroll}
68
+ className={cn('cv-wallpaper', className)}
69
+ style={{ ...(isDark ? DARK_WALLPAPER : LIGHT_WALLPAPER), ...style }}
70
+ >
71
+ {children}
72
+ </div>
73
+ )
74
+ },
75
+ )