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

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 (99) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-OIDAIVCH.js +1985 -0
  3. package/dist/flows/index.d.ts +14 -2
  4. package/dist/flows/index.js +117 -39
  5. package/dist/index.d.ts +729 -136
  6. package/dist/index.js +1418 -1104
  7. package/dist/preview/index.d.ts +373 -0
  8. package/dist/preview/index.js +1247 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-O7kMP1Yn.d.ts +416 -0
  11. package/package.json +10 -3
  12. package/src/AudioRecorderButton.test.tsx +30 -0
  13. package/src/AudioRecorderButton.tsx +248 -0
  14. package/src/Avatar.tsx +30 -4
  15. package/src/ChannelIcon.tsx +87 -0
  16. package/src/ConversationContextPanel.tsx +127 -0
  17. package/src/ConversationDocumentsPanel.tsx +425 -0
  18. package/src/ConversationHeader.tsx +257 -0
  19. package/src/ConversationListItem.tsx +54 -7
  20. package/src/ConversationLocalesProvider.tsx +16 -0
  21. package/src/ConversationRow.tsx +137 -0
  22. package/src/DateDivider.tsx +16 -3
  23. package/src/DocumentsLibrary.tsx +322 -0
  24. package/src/EmojiPicker.tsx +69 -55
  25. package/src/FileIcon.test.ts +83 -0
  26. package/src/FileIcon.tsx +88 -11
  27. package/src/InteractiveMessage.test.tsx +41 -0
  28. package/src/InteractiveMessage.tsx +143 -0
  29. package/src/Lightbox.tsx +18 -3
  30. package/src/MediaRenderer.tsx +64 -21
  31. package/src/MessageBubble.tsx +54 -5
  32. package/src/MessageComposer.test.tsx +35 -0
  33. package/src/MessageComposer.tsx +146 -19
  34. package/src/RichMessageComposer.test.tsx +83 -0
  35. package/src/RichMessageComposer.tsx +380 -0
  36. package/src/Wallpaper.test.tsx +21 -0
  37. package/src/Wallpaper.tsx +54 -6
  38. package/src/WhatsAppMessageEditor.tsx +28 -4
  39. package/src/WindowExpiredNotice.tsx +57 -0
  40. package/src/audioRecorderFormat.test.ts +67 -0
  41. package/src/conversationChannel.test.ts +53 -0
  42. package/src/conversationChannel.ts +146 -0
  43. package/src/conversationTranscript.test.ts +65 -0
  44. package/src/conversationTranscript.ts +64 -0
  45. package/src/conversationWindow.test.ts +90 -0
  46. package/src/conversationWindow.ts +78 -0
  47. package/src/documentTypeLabel.test.ts +57 -0
  48. package/src/emojiCatalog.test.ts +35 -0
  49. package/src/emojiCatalog.ts +189 -0
  50. package/src/flows/FlowGroupHeader.tsx +12 -2
  51. package/src/flows/FlowMapCanvas.tsx +17 -14
  52. package/src/flows/FlowMapNode.tsx +3 -1
  53. package/src/flows/FlowNodeCard.tsx +22 -4
  54. package/src/flows/FlowNodePanel.tsx +132 -35
  55. package/src/flows/FlowPalette.tsx +6 -2
  56. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  57. package/src/flows/flowGraph.ts +5 -5
  58. package/src/flows/labels.ts +5 -0
  59. package/src/hooks/useConversationActions.ts +56 -0
  60. package/src/hooks/useConversationDocuments.ts +15 -9
  61. package/src/hooks/useConversationList.ts +15 -9
  62. package/src/hooks/useConversationMessages.ts +2 -2
  63. package/src/index.ts +129 -16
  64. package/src/lib/cn.test.ts +29 -0
  65. package/src/lib/cn.ts +15 -0
  66. package/src/lib/createMediaUrlResolver.ts +33 -0
  67. package/src/lib/paginated.test.ts +33 -0
  68. package/src/lib/paginated.ts +26 -0
  69. package/src/lib/phone.ts +34 -0
  70. package/src/preview/ConversationPreview.tsx +325 -0
  71. package/src/preview/MediaTypesPreview.tsx +87 -0
  72. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  73. package/src/preview/createMockConversationsApi.ts +271 -0
  74. package/src/preview/createMockSSEProvider.ts +40 -0
  75. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  76. package/src/preview/createPreviewBridgeClient.ts +88 -0
  77. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  78. package/src/preview/createPreviewWebhookClient.ts +131 -0
  79. package/src/preview/index.ts +61 -0
  80. package/src/preview/mediaTypeOf.test.ts +15 -0
  81. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  82. package/src/preview/mockEventSource.ts +53 -0
  83. package/src/preview/preview.test.ts +177 -0
  84. package/src/preview/previewFileSamples.test.ts +151 -0
  85. package/src/preview/previewFileSamples.ts +74 -0
  86. package/src/preview/previewFixtures.ts +440 -0
  87. package/src/preview/previewMediaSource.test.ts +62 -0
  88. package/src/preview/previewMediaSource.ts +91 -0
  89. package/src/preview/previewStore.ts +193 -0
  90. package/src/preview/startPreviewScript.ts +60 -0
  91. package/src/providers/types.ts +163 -11
  92. package/src/quickReply.test.ts +58 -0
  93. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  94. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  95. package/src/styles.css +136 -0
  96. package/src/types.ts +46 -1
  97. package/src/useDarkMode.ts +26 -0
  98. package/src/useIsNarrow.ts +29 -0
  99. package/src/useWaitingNotifications.ts +74 -29
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Gravação de áudio no simulador, pelo microfone do próprio navegador.
3
+ *
4
+ * Existe porque áudio é o formato que mais chega de cliente real e o que mais quebra fluxo: sem
5
+ * poder gravar aqui, testar o caminho de transcrição exigia mandar mensagem do celular de alguém.
6
+ *
7
+ * O arquivo gravado sai daqui como `File` e segue exatamente o mesmo caminho de um anexo — quem
8
+ * hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
9
+ */
10
+
11
+ import { useCallback, useEffect, useRef, useState } from 'react'
12
+
13
+ import { AudioPlayer } from './AudioPlayer'
14
+
15
+ export interface AudioRecorderButtonLabels {
16
+ start: string
17
+ stop: string
18
+ unsupported: string
19
+ denied: string
20
+ review: string
21
+ send: string
22
+ discard: string
23
+ empty: string
24
+ }
25
+
26
+ export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
27
+ start: 'Gravar áudio',
28
+ stop: 'Parar gravação',
29
+ unsupported: 'Este navegador não grava áudio.',
30
+ denied: 'Sem permissão para usar o microfone.',
31
+ review: 'Ouça antes de enviar',
32
+ send: 'Enviar áudio',
33
+ discard: 'Descartar áudio',
34
+ empty: 'Nada foi captado pelo microfone.',
35
+ }
36
+
37
+ export interface AudioRecorderButtonProps {
38
+ onRecorded: (file: File) => void | Promise<void>
39
+ onFailure?: (message: string) => void
40
+ /**
41
+ * Avisa quando a gravação começa e termina. O botão é um interruptor — o segundo toque é que
42
+ * envia — e sem um aviso fora dele o operador grava, não vê nada acontecer e desiste achando
43
+ * que o microfone está quebrado.
44
+ */
45
+ onRecordingChange?: (isRecording: boolean) => void
46
+ /**
47
+ * Abre uma etapa de revisão quando a gravação para: o áudio toca ali mesmo e só sai depois de
48
+ * confirmado. Ligado por padrão — voz é o único anexo que quem envia não viu antes de mandar, e
49
+ * sem ouvir não há como saber se o microfone captou alguma coisa. Desligar volta ao envio direto.
50
+ */
51
+ reviewBeforeSend?: boolean
52
+ /**
53
+ * Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
54
+ * tem. Produto com limite próprio sobrescreve.
55
+ */
56
+ maxDurationMilliseconds?: number
57
+ labels?: Partial<AudioRecorderButtonLabels>
58
+ disabled?: boolean
59
+ }
60
+
61
+ /**
62
+ * Cinco minutos: com o codec de voz do WhatsApp isso dá menos de 3MB, folgado dentro do teto de
63
+ * 16MB que a Meta impõe a áudio, e é mais do que qualquer recado de cliente. O corte automático
64
+ * existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
65
+ */
66
+ export const DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1000
67
+
68
+ /**
69
+ * Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
70
+ * saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
71
+ * do envio que o formato é inválido é pior do que gravar já no formato certo.
72
+ */
73
+ const RECORDING_FORMATS = [
74
+ { mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
75
+ { mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
76
+ { mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
77
+ ] as const
78
+
79
+ export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
80
+
81
+ /** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
82
+ export function resolveRecordingFormat(): RecordingFormat | undefined {
83
+ if (typeof MediaRecorder === 'undefined') return undefined
84
+ // `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
85
+ if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
86
+ return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
87
+ }
88
+
89
+ type PendingRecording = { file: File; objectURL: string }
90
+
91
+ export function AudioRecorderButton({
92
+ onRecorded,
93
+ onFailure,
94
+ onRecordingChange,
95
+ reviewBeforeSend = true,
96
+ maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
97
+ labels,
98
+ disabled,
99
+ }: AudioRecorderButtonProps) {
100
+ const labelOf = (key: keyof AudioRecorderButtonLabels): string =>
101
+ labels?.[key] ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS[key]
102
+ const [isRecording, setIsRecording] = useState(false)
103
+ const [pending, setPending] = useState<PendingRecording | undefined>(undefined)
104
+ const recorderRef = useRef<MediaRecorder | null>(null)
105
+ const autoStopRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
106
+
107
+ // O `objectURL` é um recurso do documento, não do React: sem revogar, cada gravação descartada
108
+ // deixa o blob inteiro preso na memória da aba até um reload.
109
+ const discard = useCallback(() => {
110
+ setPending((current) => {
111
+ if (current) URL.revokeObjectURL(current.objectURL)
112
+ return undefined
113
+ })
114
+ }, [])
115
+
116
+ useEffect(() => discard, [discard])
117
+
118
+ const confirm = useCallback(() => {
119
+ if (!pending) return
120
+ void onRecorded(pending.file)
121
+ discard()
122
+ }, [discard, onRecorded, pending])
123
+
124
+ const stop = useCallback(() => {
125
+ recorderRef.current?.stop()
126
+ }, [])
127
+
128
+ const start = useCallback(async () => {
129
+ const format = resolveRecordingFormat()
130
+ if (!format || !navigator.mediaDevices?.getUserMedia) {
131
+ onFailure?.(labels?.unsupported ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.unsupported)
132
+ return
133
+ }
134
+
135
+ try {
136
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
137
+ const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
138
+ const chunks: Blob[] = []
139
+
140
+ recorder.addEventListener('dataavailable', (event) => {
141
+ if (event.data.size > 0) chunks.push(event.data)
142
+ })
143
+ recorder.addEventListener('stop', () => {
144
+ // Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
145
+ // aceso depois do envio, e o operador acha que o simulador continua ouvindo.
146
+ stream.getTracks().forEach((track) => track.stop())
147
+ clearTimeout(autoStopRef.current)
148
+ setIsRecording(false)
149
+ onRecordingChange?.(false)
150
+ recorderRef.current = null
151
+ // O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
152
+ // gravador, mas quem valida upload compara com `audio/ogg` puro.
153
+ const blob = new Blob(chunks, { type: format.uploadMimeType })
154
+ const file = new File([blob], `audio-${Date.now()}.${format.extension}`, {
155
+ type: format.uploadMimeType,
156
+ })
157
+
158
+ // Gravação vazia não vira anexo: microfone mudo ou permissão revogada no meio produzem um
159
+ // blob de zero byte, e mandá-lo adiante só falha lá na frente, sem dizer por quê.
160
+ if (blob.size === 0) {
161
+ onFailure?.(labelOf('empty'))
162
+ return
163
+ }
164
+
165
+ if (!reviewBeforeSend) {
166
+ void onRecorded(file)
167
+ return
168
+ }
169
+
170
+ setPending({ file, objectURL: URL.createObjectURL(blob) })
171
+ })
172
+
173
+ recorderRef.current = recorder
174
+ recorder.start()
175
+ autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds)
176
+ setIsRecording(true)
177
+ onRecordingChange?.(true)
178
+ } catch {
179
+ onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
180
+ }
181
+ // `labelOf` lê `labels` a cada render e não entra aqui; o que importa para recriar o gravador
182
+ // são o teto de duração, o destino da gravação e se há etapa de revisão.
183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
184
+ }, [maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange, reviewBeforeSend])
185
+
186
+ const toggleLabel = isRecording ? labelOf('stop') : labelOf('start')
187
+
188
+ return (
189
+ /* O painel de revisão flutua sobre o botão em vez de ocupar espaço na barra: o microfone mora
190
+ na caixa do botão de enviar, e empurrar o composer para cima a cada gravação faria a
191
+ conversa saltar. */
192
+ <div className="relative flex-shrink-0">
193
+ {pending && (
194
+ <div
195
+ role="group"
196
+ aria-label={labelOf('review')}
197
+ /* Largura presa à viewport, não ao conteúdo: ancorado à direita do microfone, qualquer
198
+ largura fixa maior que a tela sangra para fora no celular. `flex-wrap` é a segunda
199
+ rede — se o player e os dois botões não couberem lado a lado, os botões descem. */
200
+ className="absolute bottom-full right-0 z-20 mb-2 flex w-[calc(100vw-2rem)] max-w-[20rem] flex-wrap items-center justify-end gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800"
201
+ >
202
+ <div className="min-w-0 flex-1">
203
+ <AudioPlayer src={pending.objectURL} />
204
+ </div>
205
+ <button
206
+ type="button"
207
+ onClick={discard}
208
+ title={labelOf('discard')}
209
+ aria-label={labelOf('discard')}
210
+ className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-red-500 dark:hover:bg-gray-700"
211
+ >
212
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
213
+ </button>
214
+ <button
215
+ type="button"
216
+ onClick={confirm}
217
+ title={labelOf('send')}
218
+ aria-label={labelOf('send')}
219
+ className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
220
+ >
221
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /></svg>
222
+ </button>
223
+ </div>
224
+ )}
225
+ <button
226
+ type="button"
227
+ disabled={disabled || pending !== undefined}
228
+ onClick={() => (isRecording ? stop() : void start())}
229
+ title={toggleLabel}
230
+ aria-label={toggleLabel}
231
+ aria-pressed={isRecording}
232
+ /* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
233
+ vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
234
+ className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors disabled:opacity-50 ${
235
+ isRecording
236
+ ? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
237
+ : 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
238
+ }`}
239
+ >
240
+ {isRecording ? (
241
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
242
+ ) : (
243
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
244
+ )}
245
+ </button>
246
+ </div>
247
+ )
248
+ }
package/src/Avatar.tsx CHANGED
@@ -1,8 +1,18 @@
1
+ export interface AvatarLabels {
2
+ /** Lido por leitor de tela quando não há nome nem imagem — a silhueta genérica. */
3
+ unnamedContact: string
4
+ }
5
+
1
6
  export interface AvatarProps {
2
7
  name?: string | null
3
8
  avatarUrl?: string
4
9
  size?: 'sm' | 'md' | 'lg'
5
10
  className?: string
11
+ labels?: Partial<AvatarLabels>
12
+ }
13
+
14
+ export const DEFAULT_AVATAR_LABELS: AvatarLabels = {
15
+ unnamedContact: 'Contato sem nome',
6
16
  }
7
17
 
8
18
  const sizeClasses = {
@@ -36,8 +46,9 @@ function getBgColor(name: string | undefined | null): string {
36
46
 
37
47
  // Paridade com financiamento-imobiliario-bot/apps/web/src/components/Avatar.tsx —
38
48
  // mesmas iniciais (primeira+última letra), mesmo hash de cor, mesmas classes de tamanho.
39
- export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarProps) {
49
+ export function Avatar({ name, avatarUrl, size = 'md', className = '', labels }: AvatarProps) {
40
50
  const sizeClass = sizeClasses[size]
51
+ const unnamedContactLabel = labels?.unnamedContact ?? DEFAULT_AVATAR_LABELS.unnamedContact
41
52
 
42
53
  if (avatarUrl) {
43
54
  return (
@@ -49,15 +60,30 @@ export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarP
49
60
  )
50
61
  }
51
62
 
52
- const initials = getInitials(name)
53
63
  const bgColor = getBgColor(name)
54
64
 
65
+ // Sem nome e sem imagem, a silhueta genérica. Iniciais exigem nome — derivá-las de um telefone
66
+ // produz rótulo sem significado ("+9"), que é pior que assumir o anonimato.
67
+ if (!name) {
68
+ return (
69
+ <div
70
+ className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white flex-shrink-0 ${className}`}
71
+ role="img"
72
+ aria-label={unnamedContactLabel}
73
+ >
74
+ <svg viewBox="0 0 24 24" fill="currentColor" className="h-[60%] w-[60%]" aria-hidden>
75
+ <path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5Z" />
76
+ </svg>
77
+ </div>
78
+ )
79
+ }
80
+
55
81
  return (
56
82
  <div
57
83
  className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 ${className}`}
58
- title={name || undefined}
84
+ title={name}
59
85
  >
60
- {initials}
86
+ {getInitials(name)}
61
87
  </div>
62
88
  )
63
89
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Marca visual de cada canal.
3
+ *
4
+ * SVG em vez de emoji porque emoji não tem logo de marca: 💬 e 📨 não distinguem WhatsApp de
5
+ * Messenger para quem bate o olho numa fila de conversas. São marcas **simplificadas** desenhadas
6
+ * com primitivas — reconhecíveis pela forma e pela cor oficial, sem reproduzir o logotipo original.
7
+ *
8
+ * `currentColor` não serve aqui: a cor faz parte da identificação do canal.
9
+ */
10
+
11
+ import { CONVERSATION_CHANNEL, capabilitiesOf, type ConversationChannel } from './conversationChannel'
12
+
13
+ export const CHANNEL_BRAND_COLOR: Readonly<Record<ConversationChannel, string>> = {
14
+ [CONVERSATION_CHANNEL.WHATSAPP]: '#25D366',
15
+ [CONVERSATION_CHANNEL.MESSENGER]: '#0084FF',
16
+ [CONVERSATION_CHANNEL.INSTAGRAM]: '#E4405F',
17
+ [CONVERSATION_CHANNEL.WEBCHAT]: '#64748B',
18
+ }
19
+
20
+ export interface ChannelIconProps {
21
+ channel?: ConversationChannel | undefined
22
+ size?: number
23
+ className?: string
24
+ }
25
+
26
+ function Glyph({ channel }: { channel: ConversationChannel }) {
27
+ const color = CHANNEL_BRAND_COLOR[channel]
28
+
29
+ if (channel === CONVERSATION_CHANNEL.WHATSAPP) {
30
+ return (
31
+ <>
32
+ <circle cx="12" cy="12" r="11" fill={color} />
33
+ {/* Handset estilizado, o traço que identifica a marca à distância. */}
34
+ <path
35
+ d="M8.6 7.2c.3-.1.7 0 .9.3l1 1.6c.2.3.1.7-.1.9l-.7.7c.6 1.3 1.6 2.3 2.9 2.9l.7-.7c.2-.2.6-.3.9-.1l1.6 1c.3.2.4.6.3.9-.4 1-1.4 1.6-2.4 1.4-3-.5-5.4-2.9-5.9-5.9-.2-1 .4-2 1.4-2.4Z"
36
+ fill="#fff"
37
+ />
38
+ </>
39
+ )
40
+ }
41
+
42
+ if (channel === CONVERSATION_CHANNEL.MESSENGER) {
43
+ return (
44
+ <>
45
+ <circle cx="12" cy="12" r="11" fill={color} />
46
+ {/* Raio do Messenger. */}
47
+ <path d="M6.5 15.5 11 10.8l2.4 2.4L17.5 9l-4.6 4.9-2.4-2.4-4 4Z" fill="#fff" stroke="#fff" strokeWidth="1.4" strokeLinejoin="round" />
48
+ </>
49
+ )
50
+ }
51
+
52
+ if (channel === CONVERSATION_CHANNEL.INSTAGRAM) {
53
+ return (
54
+ <>
55
+ <rect x="2" y="2" width="20" height="20" rx="6" fill={color} />
56
+ <rect x="6.5" y="6.5" width="11" height="11" rx="3.5" fill="none" stroke="#fff" strokeWidth="1.6" />
57
+ <circle cx="12" cy="12" r="2.6" fill="none" stroke="#fff" strokeWidth="1.6" />
58
+ <circle cx="16.4" cy="7.6" r="1" fill="#fff" />
59
+ </>
60
+ )
61
+ }
62
+
63
+ return (
64
+ <>
65
+ <circle cx="12" cy="12" r="11" fill={color} />
66
+ <path d="M6.5 8.5h11v7h-5.5L8.5 18v-2.5h-2v-7Z" fill="#fff" />
67
+ </>
68
+ )
69
+ }
70
+
71
+ export function ChannelIcon({ channel, size = 14, className = '' }: ChannelIconProps) {
72
+ const resolved: ConversationChannel = channel ?? CONVERSATION_CHANNEL.WHATSAPP
73
+ const { label } = capabilitiesOf(resolved)
74
+
75
+ return (
76
+ <svg
77
+ viewBox="0 0 24 24"
78
+ width={size}
79
+ height={size}
80
+ className={`inline-block shrink-0 ${className}`.trim()}
81
+ role="img"
82
+ aria-label={label}
83
+ >
84
+ <Glyph channel={resolved} />
85
+ </svg>
86
+ )
87
+ }
@@ -0,0 +1,127 @@
1
+ /**
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.
4
+ *
5
+ * O pacote não interpreta o contexto — ele é `Record<string, unknown>` e cada produto nomeia as
6
+ * próprias chaves. O host traduz para `entries`; aqui só se decide como mostrar.
7
+ */
8
+
9
+ import { useState } from 'react'
10
+
11
+ import { cn } from './lib/cn'
12
+ import { useIsNarrow } from './useIsNarrow'
13
+
14
+ export interface ConversationContextEntry {
15
+ key: string
16
+ label: string
17
+ value?: string | undefined
18
+ icon?: string
19
+ }
20
+
21
+ export interface ConversationContextPanelLabels {
22
+ title: string
23
+ empty: string
24
+ collapse: string
25
+ expand: string
26
+ }
27
+
28
+ export const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels = {
29
+ title: '📋 Suas Seleções',
30
+ empty: 'Nada coletado ainda nesta conversa.',
31
+ collapse: 'fechar',
32
+ expand: 'abrir',
33
+ }
34
+
35
+ export interface ConversationContextPanelClassNames {
36
+ root: string
37
+ toggle: string
38
+ counter: string
39
+ body: string
40
+ }
41
+
42
+ export interface ConversationContextPanelProps {
43
+ entries: readonly ConversationContextEntry[]
44
+ /**
45
+ * Estado inicial. Ausente, abre sozinho no desktop quando há algum dado preenchido.
46
+ *
47
+ * 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.
49
+ */
50
+ defaultOpen?: boolean
51
+ labels?: Partial<ConversationContextPanelLabels>
52
+ className?: string
53
+ classNames?: Partial<ConversationContextPanelClassNames>
54
+ }
55
+
56
+ export function ConversationContextPanel({
57
+ entries,
58
+ defaultOpen,
59
+ labels: labelsOverride,
60
+ className,
61
+ classNames,
62
+ }: ConversationContextPanelProps) {
63
+ const labels = { ...DEFAULT_CONVERSATION_CONTEXT_LABELS, ...labelsOverride }
64
+ const filled = entries.filter((entry) => Boolean(entry.value))
65
+
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.
68
+ //
69
+ // `undefined` = usuário ainda não mexeu. Guardar só um booleano não serve: o contexto chega
70
+ // depois da montagem, então o estado inicial seria calculado com a lista vazia e o painel ficaria
71
+ // fechado mesmo com dados.
72
+ const isNarrow = useIsNarrow()
73
+ 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)
77
+
78
+ 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}
85
+ 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,
88
+ )}
89
+ >
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>
122
+ )}
123
+ </div>
124
+ ) : null}
125
+ </section>
126
+ )
127
+ }